@tiens.nguyen/gu-cli 1.0.792 → 1.0.794

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -992,6 +992,80 @@ def _web_search_impl(query, max_results=5, searxng_url="",
992
992
  # the text still reaches both UIs, the prefix tells them it is a failure, not a reply.
993
993
  TURN_FAILED = "__TURN_FAILED__::"
994
994
 
995
+ # ANOTHER MODEL STOOD IN (task #343). Reported live: the coding model had a problem, a different
996
+ # model answered, and nothing on screen said so — the turn "worked", only with a model nobody
997
+ # picked. Every place in this file where one model takes over from another now says so with ONE
998
+ # event, a step carrying this prefix and a JSON body, so the terminal can hold it in yellow on the
999
+ # bottom bar. A step, like __RAGSTATE__::, so it rides the job's chunk stream and still arrives
1000
+ # when the agent runs on a different machine than the terminal.
1001
+ #
1002
+ # What is NOT a fallback and must never be reported as one: a retry on the SAME model, and the
1003
+ # 429 / 502 exits below — those end the turn as failed, no other model answers.
1004
+ MODEL_FALLBACK = "__MODELFALLBACK__::"
1005
+
1006
+
1007
+ def _fallback_reason(err) -> str:
1008
+ """Why a model was given up on, in a few words that fit a status bar — never a stack of
1009
+ exception text (an nginx error is a whole HTML page). The status code when there is one,
1010
+ else the kind of failure, else the first line clipped."""
1011
+ if err is None:
1012
+ return ""
1013
+ status = _http_status_of(err)
1014
+ text = str(err)
1015
+ low = text.lower()
1016
+ if status in (401, 403):
1017
+ return f"{status} key refused"
1018
+ if status == 404:
1019
+ return "404 model not found"
1020
+ if status == 429 or "overloaded" in low or "rate limit" in low:
1021
+ return "429 overloaded"
1022
+ if status and status >= 500:
1023
+ return f"{status} server error"
1024
+ if "timed out" in low or "timeout" in low:
1025
+ return "timed out"
1026
+ if "empty response" in low:
1027
+ return "empty reply"
1028
+ if "context" in low and ("length" in low or "window" in low or "too long" in low or "exceed" in low):
1029
+ return "context too large"
1030
+ if "connection" in low or "connect" in low or "refused" in low or "unreachable" in low:
1031
+ return "could not connect"
1032
+ if status:
1033
+ return f"HTTP {status}"
1034
+ first = next((ln.strip() for ln in text.splitlines() if ln.strip()), "")
1035
+ first = re.sub(r"<[^>]+>", " ", first)
1036
+ first = re.sub(r"\s+", " ", first).strip()
1037
+ return first if len(first) <= 48 else first[:47].rstrip() + "…"
1038
+
1039
+
1040
+ def _fallback_event(role, from_model, to_model, reason, *, tools=True, answered=True) -> str:
1041
+ """The step text for one fallback. Pure.
1042
+
1043
+ role "coder" — the coding model was given up on; "chat" — the chat model was
1044
+ from/to the model that had the problem, and the one used instead
1045
+ reason a few words (see _fallback_reason)
1046
+ tools False when the stand-in answered as a bare chat reply — no files, no commands
1047
+ answered False when the stand-in failed too: "fell back" is only said when it answered
1048
+ """
1049
+ body = {
1050
+ "role": "chat" if role == "chat" else "coder",
1051
+ "from": str(from_model or "").strip(),
1052
+ "to": str(to_model or "").strip(),
1053
+ "reason": str(reason or "").strip(),
1054
+ "tools": bool(tools),
1055
+ "answered": bool(answered),
1056
+ }
1057
+ return MODEL_FALLBACK + json.dumps(body, ensure_ascii=False)
1058
+
1059
+
1060
+ def _report_fallback(role, from_model, to_model, reason, *, tools=True, answered=True) -> None:
1061
+ """Tell the terminal another model stood in. Logged too, so a run log shows it."""
1062
+ _log(
1063
+ f"model fallback: {role} {from_model!r} -> {to_model!r} ({reason or 'no reason'})"
1064
+ f"{'' if tools else ' — no tools'}{'' if answered else ' — it failed too'}"
1065
+ )
1066
+ _emit({"type": "step", "text": _fallback_event(
1067
+ role, from_model, to_model, reason, tools=tools, answered=answered)})
1068
+
995
1069
 
996
1070
  class _AgentConfigError(RuntimeError):
997
1071
  """Deterministic setup error (e.g. wrong model name/URL). Not retryable and not
@@ -1473,6 +1547,24 @@ def _resolve_coding_target(agent_base_url, agent_model_id, raw_coding_base, raw_
1473
1547
  return agent_base_url, agent_model_id
1474
1548
 
1475
1549
 
1550
+ def _coder_fell_back_to_chat(agent_base_url, raw_coding_base, resolved_base) -> bool:
1551
+ """Did _resolve_coding_target give the chat model a job a DIFFERENT coding server was chosen
1552
+ for? Pure. False when no coder is set, and false for a coder on the chat model's own server
1553
+ with no name — that is "use the chat model", chosen, not a fallback."""
1554
+ raw = (raw_coding_base or "").rstrip("/")
1555
+ if not raw or raw == (agent_base_url or "").rstrip("/"):
1556
+ return False
1557
+ return (resolved_base or "").rstrip("/") != raw
1558
+
1559
+
1560
+ def _coder_label(model_id, base_url) -> str:
1561
+ """What to call a coder on the bar: its name, or — when it never told us one — its host."""
1562
+ if (model_id or "").strip():
1563
+ return model_id.strip()
1564
+ host = re.sub(r"^[a-z]+://", "", (base_url or "").strip(), flags=re.I).split("/")[0]
1565
+ return host or "the coding model"
1566
+
1567
+
1476
1568
  def _coding_backend_flags(backend):
1477
1569
  """Everything the resolved backend decides, derived in ONE place (task #117 → #123).
1478
1570
 
@@ -2943,6 +3035,7 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
2943
3035
  targets.append((fallback_base_url, fallback_model_id, fallback_api_key or api_key))
2944
3036
 
2945
3037
  last_err = None
3038
+ first_err = None
2946
3039
  for i, (b, mid, key) in enumerate(targets):
2947
3040
  emitted_here = False
2948
3041
 
@@ -2981,12 +3074,22 @@ def _plain_reply(messages: list, base_url: str, api_key: str, model_id: str,
2981
3074
  last_err = RuntimeError(f"{mid} returned an empty response")
2982
3075
  except Exception as e: # noqa: BLE001
2983
3076
  last_err = e
3077
+ if i == 0:
3078
+ first_err = last_err # why the model that was ASKED gave nothing
3079
+ else:
3080
+ # The stand-in did not answer either (#343) — take back "it answered".
3081
+ _report_fallback("chat", targets[0][1], mid, _fallback_reason(first_err),
3082
+ tools=False, answered=False)
2984
3083
  if emitted_here:
2985
3084
  # Part of THIS attempt's answer already streamed to the user — don't
2986
3085
  # silently splice a DIFFERENT model's output after a partial one.
2987
3086
  break
2988
3087
  if i + 1 < len(targets):
2989
3088
  nb, nmid, _ = targets[i + 1] # the model we're about to try next
3089
+ # SAID AT THE SWITCH (#343), before the stand-in streams: a step that arrived after
3090
+ # its answer would reopen the reasoning block under the reply. Corrected just above
3091
+ # if it fails too.
3092
+ _report_fallback("chat", mid, nmid, _fallback_reason(last_err), tools=False)
2990
3093
  _log(f"plain reply: {mid} @ {b} failed ({last_err}); "
2991
3094
  f"falling back to {nmid} @ {nb}")
2992
3095
  # Every target failed or returned nothing — raise rather than return a sentinel
@@ -7671,6 +7774,11 @@ def run_agent_chat(cfg):
7671
7774
  agent_base_url, agent_model_id, raw_coding_base, raw_coding_model,
7672
7775
  agent_api_key=agent_api_key, coding_api_key=coding_api_key,
7673
7776
  )
7777
+ # A coder WAS chosen and the chat model is doing its work (#343): its server would not say
7778
+ # which model it serves. Said when the agent path starts — a plain chat turn does no coding,
7779
+ # so there is nothing to have fallen back from.
7780
+ _coder_unresolved = _coder_fell_back_to_chat(
7781
+ agent_base_url, raw_coding_base, coding_base_url)
7674
7782
  # Task #108: an OpenAI-compatible coder REQUIRES an explicit model name (its
7675
7783
  # /v1/chat/completions needs a `model` id, and we won't silently reuse the MLX chat
7676
7784
  # model's name against a cloud endpoint). Fail with a clear, actionable message.
@@ -8490,6 +8598,9 @@ def run_agent_chat(cfg):
8490
8598
  # Agent path — from here all step events go into <think>.
8491
8599
  _log("router: agent (HTTP tool use needed)")
8492
8600
  _emit({"type": "step", "text": "Choosing a tool…"})
8601
+ if _coder_unresolved:
8602
+ _report_fallback("coder", _coder_label(raw_coding_model, raw_coding_base), agent_model_id,
8603
+ "its server named no model")
8493
8604
 
8494
8605
  # Prepend explicit tool instructions so small models pick the right tool, never
8495
8606
  # fabricate URLs/responses, and always terminate with final_answer().
@@ -11293,12 +11404,19 @@ def run_agent_chat(cfg):
11293
11404
  # No usable tool output (e.g. every step's code failed to parse).
11294
11405
  # Don't dead-end on a canned apology — answer from the conversation.
11295
11406
  _log("max-steps fallback → plain reply over conversation")
11407
+ # Every step was unusable and the chat model now answers from the conversation
11408
+ # alone (#343). Said BEFORE it streams — a step after the answer would reopen
11409
+ # the reasoning block under it — and corrected below if it fails too.
11410
+ _report_fallback("coder", coding_model_id, agent_model_id,
11411
+ "no usable step", tools=False)
11296
11412
  try:
11297
11413
  text = _plain_reply(
11298
11414
  messages, agent_base_url, agent_api_key, agent_model_id
11299
11415
  ).strip()
11300
11416
  except Exception as e: # noqa: BLE001
11301
11417
  _log(f"plain-reply fallback error: {e}")
11418
+ _report_fallback("coder", coding_model_id, agent_model_id,
11419
+ "no usable step", tools=False, answered=False)
11302
11420
  text = ""
11303
11421
  if not text:
11304
11422
  text = ("I couldn't complete that within the step budget. Please "
@@ -13427,10 +13545,16 @@ def run_agent_chat(cfg):
13427
13545
  # answer from the conversation rather than surfacing a raw error to the user —
13428
13546
  # but only when the fallback ACTUALLY has something useful to say.
13429
13547
  _log(f"agent error: {e} — degrading to plain reply")
13548
+ # The tool-capable loop failed and the chat model answers instead, with no tools (#343).
13549
+ # Said BEFORE it streams; corrected in the except if it fails too.
13550
+ _report_fallback("coder", coding_model_id, agent_model_id, _fallback_reason(e), tools=False)
13430
13551
  try:
13431
13552
  fallback = _plain_reply(messages, agent_base_url, agent_api_key, agent_model_id).strip()
13432
13553
  except Exception as e2: # noqa: BLE001
13433
13554
  _log(f"plain-reply degrade error: {e2}")
13555
+ # The stand-in failed too (#343): say THAT, never "fell back".
13556
+ _report_fallback("coder", coding_model_id, agent_model_id, _fallback_reason(e),
13557
+ tools=False, answered=False)
13434
13558
  # Nothing useful to say — re-raise the ORIGINAL (more relevant) agent
13435
13559
  # failure so this becomes a genuine job failure: a red error the REPL/web
13436
13560
  # already handle correctly, NOT persisted to conversation history. Emitting
package/gu-cli.mjs CHANGED
@@ -134,6 +134,8 @@ Usage:
134
134
  gu-cli workspace revert [runId] # undo an agent run's file edits from its backups
135
135
  gu-cli mongo migrate-from-docker # macOS: move a Docker MongoDB to native (Homebrew)
136
136
  gu-cli clear [--scratch|--keep-history] [--yes] # back to a fresh machine (asks first)
137
+ gu-cli fix-path # macOS/Linux: put npm's global bin folder on PATH
138
+ # (what the install does, if npm skipped it)
137
139
  gu-cli fix-powershell [--check] # Windows: drop PowerShell's built-in \`gu\` alias
138
140
  # (it is Get-Unique and shadows this CLI)
139
141
  gu-cli rag plan [path] # is RAG worth it here? files/chars/chunks → estimated index time
@@ -1054,6 +1056,42 @@ if (args[0] === "token-usage" || args[0] === "tokens") {
1054
1056
  process.exit(0);
1055
1057
  }
1056
1058
 
1059
+ // `gu-cli fix-path` — put npm's global bin folder on PATH (task #333). The same repair the
1060
+ // postinstall does, for when npm did not run it: run through install-path.mjs itself, with the
1061
+ // prefix worked out from where this file is installed, so there is one implementation.
1062
+ if (args[0] === "fix-path") {
1063
+ if (process.platform === "win32") {
1064
+ console.log(" Nothing to fix here — on Windows npm's folder is on PATH through the registry.");
1065
+ console.log(" If `gu` is not found in PowerShell: gu-cli fix-powershell");
1066
+ process.exit(0);
1067
+ }
1068
+ const { globalPrefixOf } = await import("./install-path.mjs");
1069
+ const { fileURLToPath } = await import("node:url");
1070
+ const { dirname, join } = await import("node:path");
1071
+ const here = dirname(fileURLToPath(import.meta.url));
1072
+ const prefix = globalPrefixOf(here);
1073
+ if (!prefix) {
1074
+ console.log(` This gu is not a global npm install (${here}) — there is no PATH to fix.`);
1075
+ process.exit(0);
1076
+ }
1077
+ const binDir = join(prefix, "bin");
1078
+ if ((process.env.PATH || "").split(":").includes(binDir)) {
1079
+ console.log(` Nothing to fix — ${binDir} is already on PATH.`);
1080
+ process.exit(0);
1081
+ }
1082
+ const { spawnSync } = await import("node:child_process");
1083
+ const { CI: _ci, ...env } = process.env;
1084
+ const r = spawnSync(process.execPath, [join(here, "install-path.mjs")], {
1085
+ encoding: "utf8",
1086
+ env: { ...env, npm_config_prefix: prefix, npm_config_global: "true" },
1087
+ });
1088
+ const said = `${r.stdout ?? ""}${r.stderr ?? ""}`.trim();
1089
+ // It prints only when it wrote something. Silence means the line is already in the profile
1090
+ // and this terminal simply predates it.
1091
+ console.log(said || ` Your shell profile already adds ${binDir} to PATH — open a new terminal.`);
1092
+ process.exit(r.status ?? 0);
1093
+ }
1094
+
1057
1095
  // `gu-cli fix-powershell` — drop PowerShell's built-in `gu` alias so the command works there.
1058
1096
  // ASKS FIRST and never runs as part of an install: a PowerShell profile is the user's file, and
1059
1097
  // a CLI that edits it unannounced is one that surprises people. --check only reports.
package/gu-repl.mjs CHANGED
@@ -110,6 +110,8 @@ import {
110
110
  workspaceScopeWarning,
111
111
  isLocalApiBase,
112
112
  modeRow,
113
+ nothingToAnswerWith,
114
+ NO_MODEL_LINES,
113
115
  resizeRepaintTarget,
114
116
  workspaceSyncQuestion,
115
117
  workspaceSyncLabel,
@@ -136,6 +138,11 @@ import { unapprovedProjectServers } from "./mcp-command.mjs";
136
138
  import { readHooks } from "./hooks-config.mjs";
137
139
  import { readSkillSource, sourceLabel, SKILL_SOURCE_KEY } from "./skill-source.mjs";
138
140
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
141
+ import { thoughtPrintSeq } from "./terminal-layout.mjs";
142
+ import {
143
+ MODELFALLBACK_PREFIX, fallbackAfterTurn, fallbackHistoryLine, fallbackNotice, keyMissingNotice,
144
+ parseModelFallback,
145
+ } from "./model-fallback.mjs";
139
146
  import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
140
147
  import { IMAGE_NEEDS_CLAUDE, coderTakesImagesFor, clipboardImageToFile, fitImageNotice, imageMarkersForDisplay, createImageStore, imagePasteKey, imagePathFromPaste, isClaudeCoderUrl, isImagePasteKey, storeImageFile } from "./image-paste.mjs";
141
148
  import {
@@ -665,6 +672,26 @@ if (!workerKey || !apiBase) {
665
672
  input: process.stdin,
666
673
  output: process.stdout,
667
674
  });
675
+ // Ctrl+C AND Ctrl+D MUST END THE RUN CLEANLY (task #333). readline swallows Ctrl+C in raw
676
+ // mode: with no listener it only pauses, the pending question never settles, and Node
677
+ // exits with "Warning: Detected unsettled top-level await" and a stack line — at the
678
+ // models menu of a first install. It also meant the "stopped during <step>" record
679
+ // (markInterruptedOnExit listens on the PROCESS) never fired for a Ctrl+C typed here.
680
+ let rl0Done = false;
681
+ const { stopFirstRun } = await import("./first-run-steps.mjs");
682
+ const endFirstRun = (reason) =>
683
+ stopFirstRun({
684
+ reason,
685
+ hasProcessListener: process.listenerCount("SIGINT") > 0,
686
+ emitSigint: () => process.emit("SIGINT"),
687
+ log: (t) => console.log(t),
688
+ exit: (code) => process.exit(code),
689
+ style: { yellow, dim },
690
+ });
691
+ rl0.on("SIGINT", () => endFirstRun("interrupt"));
692
+ rl0.on("close", () => {
693
+ if (!rl0Done) endFirstRun("eof");
694
+ });
668
695
  const ask0 = (q) =>
669
696
  new Promise((res) => rl0.question(accent(q), (a) => res(String(a || ""))));
670
697
  let typed = "";
@@ -682,7 +709,7 @@ if (!workerKey || !apiBase) {
682
709
  const {
683
710
  probeMachine, askMode, runClientExpertSetup, loadState, saveState,
684
711
  } = await import("./client-expert-run.mjs");
685
- const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode, promptForLicenseToken } =
712
+ const { clientExpertAvailable, parseLicenseFlag, resolveFirstRunMode, licenseUnverifiedLine, promptForLicenseToken } =
686
713
  await import("./client-expert-setup.mjs");
687
714
  const machine0 = await probeMachine();
688
715
  const gate0 = clientExpertAvailable({ workerEnvExists: false, ...machine0 });
@@ -701,7 +728,9 @@ if (!workerKey || !apiBase) {
701
728
  // (task #13). Format is checked first (no network), then offline signature, then the
702
729
  // live API. Empty Enter declines → the private default. Unreachable API still opens
703
730
  // the menu (soft licence: an outage never blocks a paid user).
731
+ const { setupPrompts: tokenPrompts } = await import("./setup-prompts.mjs");
704
732
  const pick = await promptForLicenseToken(ask0, licenseToken0, {
733
+ askSecret: tokenPrompts(rl0, { style: accent }).askSecret,
705
734
  validateOpts: {
706
735
  hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
707
736
  },
@@ -710,7 +739,7 @@ if (!workerKey || !apiBase) {
710
739
  if (pick.declined || !pick.token) {
711
740
  plan0 = resolveFirstRunMode({ licenseToken: "", expertAvailable: gate0.available });
712
741
  } else if (pick.result && pick.result.reason === "unverified") {
713
- console.log(dim("\n Couldn't reach the license server to confirm the token — showing the menu anyway."));
742
+ console.log(dim("\n " + licenseUnverifiedLine(pick.result.detail)));
714
743
  }
715
744
  }
716
745
  let mode0;
@@ -782,6 +811,7 @@ if (!workerKey || !apiBase) {
782
811
  typed = (await ask0(" worker key (or Enter to sign in): ")).trim();
783
812
  }
784
813
  } finally {
814
+ rl0Done = true;
785
815
  rl0.close();
786
816
  }
787
817
  if (stopAfterSetup) {
@@ -1599,6 +1629,9 @@ let imageNoticeTimer = null;
1599
1629
  // its own model is unset, and choosing that model (/agent-model, /embed-model, /setup, or the
1600
1630
  // API seeding one) clears only that one.
1601
1631
  let agentMissing = false; // no agent model chosen → agent falls back to the coding model
1632
+ // NO MODEL AT ALL (#99): neither an agent model nor a coder — there is no server to send a
1633
+ // question to. Seeded from the startup payload; re-checked before a question is refused.
1634
+ let noModelAtAll = false;
1602
1635
  let embedMissing = false; // no embed model chosen → RAG is off until /embed-model picks one
1603
1636
  // One footer row; the two halves are independent so a model that IS set is never named.
1604
1637
  // ONE SHORT ROW EACH (#317): joined, they ran past 80 columns and were cut mid-word.
@@ -1607,6 +1640,28 @@ const missingNoticeRows = () => [
1607
1640
  ...(embedMissing ? ["⚠ no embed model — RAG is off · /embed-model"] : []),
1608
1641
  ];
1609
1642
 
1643
+ // ANOTHER MODEL STOOD IN (task #343). The agent reports it as a __MODELFALLBACK__:: step; it is
1644
+ // held here and shown in YELLOW on the bar — from the moment it happens, through the rest of the
1645
+ // turn, and on while it stays true: until a turn is answered with no fallback (the model works
1646
+ // again) or another model is picked. `turnFallback` is the one reported during the CURRENT turn.
1647
+ let modelFallback = null;
1648
+ let turnFallback = null;
1649
+ function setModelFallback(fb) {
1650
+ modelFallback = fb;
1651
+ if (barPinned()) paintBar();
1652
+ }
1653
+ // Its rows: the reported fallback, and — the one fallback the agent cannot report, because it
1654
+ // never sees the coder the API dropped — a coding model with no API key (task #222).
1655
+ const fallbackNoticeRows = () => {
1656
+ const width = process.stdout.columns || 80;
1657
+ return [
1658
+ ...(modelFallback ? [fallbackNotice(modelFallback, { width, gutter: GUTTER })] : []),
1659
+ ...(codingKeyMissingKnown === true
1660
+ ? [keyMissingNotice({ coder: activeCodingModelId(), width, gutter: GUTTER })]
1661
+ : []),
1662
+ ];
1663
+ };
1664
+
1610
1665
  // #307: the calm note after a quick change of mind — dim, not the ⚠ orange of a warning, and in the
1611
1666
  // bar rather than the transcript (which the whole point is to leave untouched).
1612
1667
  let stopNotice = "";
@@ -2277,7 +2332,7 @@ async function refreshCliToken() {
2277
2332
  let state = await ct.readState(dir);
2278
2333
  const now = new Date();
2279
2334
  const noticeNow = () =>
2280
- ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site });
2335
+ ct.barNotice({ token: state.token, publicKeyB64: pk, mode, now: new Date(), attempted: Boolean(state.lastAttemptAt), site, why: state.lastAttemptWhy ?? "" });
2281
2336
  tokenNotice = noticeNow();
2282
2337
 
2283
2338
  const early = Boolean(tokenNotice) && !startupRenewDone;
@@ -2296,7 +2351,8 @@ async function refreshCliToken() {
2296
2351
  // A reply is only kept if it checks out here — a proxy's HTML page or another server's token
2297
2352
  // must not replace a good one.
2298
2353
  const fresh = r.ok && ct.verifyCliToken(r.token, pk).ok ? r.token : state.token;
2299
- state = { token: fresh, lastAttemptAt: now.toISOString() };
2354
+ // WHY it failed is kept with the attempt, for the bar (#102).
2355
+ state = { token: fresh, lastAttemptAt: now.toISOString(), lastAttemptWhy: r.ok ? "" : String(r.why ?? "") };
2300
2356
  await ct.writeState(dir, state).catch(() => {});
2301
2357
  tokenNotice = noticeNow();
2302
2358
  }
@@ -2568,7 +2624,9 @@ const footerRows = () =>
2568
2624
  ? dim(clipVisible(stopNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
2569
2625
  : "",
2570
2626
  // Task #14: the skipped-setup warning — persistent (not a timed notice), red, its own row.
2571
- missing: missingNoticeRows().map((r) => red(r)),
2627
+ // …then the fallback rows (#343), YELLOW: a different model is doing the work — a warning,
2628
+ // where the red rows above mean something is not set up at all.
2629
+ missing: [...missingNoticeRows().map((r) => red(r)), ...fallbackNoticeRows().map((r) => yellow(r))],
2572
2630
  });
2573
2631
  // An ask()'s hint lines (#305), drawn in yellow under its input while it waits. Plain text.
2574
2632
  let askHint = [];
@@ -3328,7 +3386,7 @@ const thoughtWidth = () =>
3328
3386
  * Progress recedes and the answer is the only full-bright thing on screen — a page of bright
3329
3387
  * reasoning per step would bury the deliverable it is supposed to lead up to.
3330
3388
  */
3331
- function printThoughtBlock(raw, before) {
3389
+ function printThoughtBlock(raw, before, { gap = false } = {}) {
3332
3390
  if (!sessionShowThought) return false;
3333
3391
  const prose = thoughtProse(raw);
3334
3392
  if (!prose) return false; // a bare tool label or pure code — not what anyone means by a thought
@@ -3375,8 +3433,14 @@ function printThoughtBlock(raw, before) {
3375
3433
  }
3376
3434
  // Scrolled out of the buffer — fall through and print a new block rather than lose it.
3377
3435
  }
3378
- process.stdout.write(rows.map((r) => GUTTER + dim(r)).join("\n") + "\n");
3379
- // Record where it landed. Measured AFTER the write so a trim during it cannot skew the
3436
+ // ONE EMPTY LINE ABOVE A NEW THOUGHT (#344) when the row above it is not already blank.
3437
+ // Reported with a screenshot: "The download is moving…" sat directly under "✓ Command finished
3438
+ // → python3 -c …" and read as that command's OUTPUT — and under a bright "✗ Command failed" row,
3439
+ // as its error text. A thought is printed when its step's reasoning ends, BEFORE that step's
3440
+ // tools report: it introduces what comes next, it does not belong to the row above. Only for a
3441
+ // NEW block — a continuation is merged into the previous one above and never gets here.
3442
+ process.stdout.write(thoughtPrintSeq(rows.map((r) => GUTTER + dim(r)), { gap }));
3443
+ // Record where it landed. The blank is above `from`, so the block is still exactly its rows. Measured AFTER the write so a trim during it cannot skew the
3380
3444
  // range: the last completed line is length-2 (the final entry is the still-open line).
3381
3445
  const endAbs = transcript.dropped() + transcript.length() - 2;
3382
3446
  thoughtBlocks.push({ from: endAbs - rows.length + 1, to: endAbs, prose, collapsed: true });
@@ -6692,6 +6756,7 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
6692
6756
  console.log(GUTTER + dim("restart it with `gu api restart`, then add that provider again.\n"));
6693
6757
  }
6694
6758
  sessionCodingModel = offered.id;
6759
+ setModelFallback(null); // #343
6695
6760
  sessionCodingLabel = model;
6696
6761
  sessionCodingUrl = url;
6697
6762
  // …and the host the bar puts in front of it (#263): left alone, the PREVIOUS coder's
@@ -6831,6 +6896,7 @@ async function chooseModel() {
6831
6896
  // Empty override means "use the account default" — so don't send an override when the
6832
6897
  // user picks the default (keeps the payload clean and lets a later default change win).
6833
6898
  sessionCodingModel = chosen.id === def ? "" : chosen.id;
6899
+ setModelFallback(null); // #343: the warning named the coder that was just replaced
6834
6900
  // Remember the friendly name for the token footer, which should never show a raw
6835
6901
  // "<kind>::<model>" wire id.
6836
6902
  sessionCodingLabel = chosen.id === def ? "" : chosen.model;
@@ -10453,8 +10519,13 @@ async function tryStartLocalApi() {
10453
10519
  );
10454
10520
  const version = versions.pop();
10455
10521
  if (!version) return false; // nothing installed — setup never got that far
10522
+ const portBefore = ce.LOCAL_API_PORT;
10456
10523
  await ce.startLocalApi({ apiDir: join(base, version) });
10457
- return (await ce.waitForApi({ timeoutMs: 30_000 })).ok;
10524
+ const up = (await ce.waitForApi({ timeoutMs: 30_000 })).ok;
10525
+ // The port can move here (#337: the saved one now belongs to another program). worker.env
10526
+ // was re-pointed; THIS terminal still holds the old address until it reads the file again.
10527
+ if (ce.LOCAL_API_PORT !== portBefore) reloadWorkerEnv();
10528
+ return up;
10458
10529
  } catch {
10459
10530
  return false;
10460
10531
  }
@@ -10786,6 +10857,22 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
10786
10857
  // from the gu service with it. Asked for HERE, before anything is sent, with the steps — rather
10787
10858
  // than letting the turn fail and leaving the user to decode why.
10788
10859
  if (!skipAsk && !(await ensureTokenForTurn())) return;
10860
+ // NOTHING TO ANSWER WITH (#99). On an account with no models at all the question used to be
10861
+ // sent, sat at "queued — waiting for the worker…", and ended in "✗ Job not found." — the job
10862
+ // had no model server in it, so nothing could read or claim it. Say what is missing instead.
10863
+ // Asked again first: /model or /agent-model may have set one since gu started.
10864
+ if (!skipAsk && noModelAtAll) {
10865
+ try {
10866
+ noModelAtAll = nothingToAnswerWith((await fetchAgentPayload([{ role: "user", content: "." }]))?.payload);
10867
+ } catch {
10868
+ /* cannot ask → keep what startup saw */
10869
+ }
10870
+ if (noModelAtAll) {
10871
+ for (const l of NO_MODEL_LINES) console.log(GUTTER + (l === NO_MODEL_LINES[0] ? red("✗ ") + l : dim(" " + l)));
10872
+ console.log("");
10873
+ return;
10874
+ }
10875
+ }
10789
10876
  inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
10790
10877
  // Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
10791
10878
  // the next thing to draw itself is the turn's ticker — which stays silent until the daemon
@@ -11010,7 +11097,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11010
11097
  if (heldThought === null) return;
11011
11098
  const raw = heldThought;
11012
11099
  heldThought = null;
11013
- if (printThoughtBlock(raw, onContent)) lastWasBlank = false;
11100
+ if (printThoughtBlock(raw, onContent, { gap: !lastWasBlank })) lastWasBlank = false;
11014
11101
  };
11015
11102
  let hoverRow = -1; // last mouse viewport row (from motion events) → highlight when it's on the thought line
11016
11103
  let thoughtRow = -1; // absolute viewport row of the thought line (line 1), learned via \x1b[6n
@@ -11435,10 +11522,13 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11435
11522
  // changed since is the VOLUME: a long turn now prints dozens of bullets interleaved with
11436
11523
  // collapsed thought prose, and with no blanks anywhere the two run together into a wall.
11437
11524
  //
11438
- // Above rather than below, for two reasons: a trailing blank would collide with the seam the
11439
- // caller owns before the bright answer (LINE_SPACING), and leading the group means the
11440
- // thought prose that FOLLOWS a bullet still sits tight against it — which is right, since
11441
- // that prose is the bullet's own reasoning, not the next step.
11525
+ // Above rather than below: a trailing blank would collide with the seam the caller owns
11526
+ // before the bright answer (LINE_SPACING).
11527
+ //
11528
+ // A THOUGHT GETS THE SAME BLANK ABOVE IT (#344, see printThoughtBlock). This comment used to say
11529
+ // the prose after a bullet "sits tight against it — which is right, since that prose is the
11530
+ // bullet's own reasoning". It is not: the thought is printed when the NEXT step's reasoning
11531
+ // ends, so glued under "Command finished → …" it read as that command's output.
11442
11532
  //
11443
11533
  // `lastWasBlank` makes it idempotent: nothing doubles a blank that is already there.
11444
11534
  const openBullet = () => {
@@ -11459,7 +11549,10 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11459
11549
  const at = thoughtBlocks.indexOf(stepGroup);
11460
11550
  if (at < 0) return false; // dropped from the registry (it is capped): no longer ours to rewrite
11461
11551
  for (let i = at + 1; i < thoughtBlocks.length; i++) {
11462
- if (thoughtBlocks[i].from !== end + 1) return false;
11552
+ // Directly under, or under the ONE blank a thought now opens with (#344) — the look-up
11553
+ // group must stay open across it, or every thought would start a new counted row.
11554
+ const step = thoughtBlocks[i].from - end;
11555
+ if (step !== 1 && step !== 2) return false;
11463
11556
  end = thoughtBlocks[i].to;
11464
11557
  }
11465
11558
  return end === transcriptTail();
@@ -11562,6 +11655,18 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11562
11655
  lastContentAt = Date.now();
11563
11656
  continue;
11564
11657
  }
11658
+ // Task #343: another model took over. Swallowed from the action list — it goes on the
11659
+ // BAR, now, in yellow — and the turn leaves one line about it once it has ended (see the
11660
+ // caller), so a fallback that lasted a single step can still be found afterwards.
11661
+ if (line.startsWith(MODELFALLBACK_PREFIX)) {
11662
+ const fb = parseModelFallback(line);
11663
+ if (fb) {
11664
+ turnFallback = fb;
11665
+ setModelFallback(fb);
11666
+ }
11667
+ lastContentAt = Date.now();
11668
+ continue;
11669
+ }
11565
11670
  // A fence opening starts a fresh step's raw stream → reset the harvested Thought
11566
11671
  // so the blinking line doesn't carry last step's clause into this one.
11567
11672
  if (isFenceLine(line)) {
@@ -11575,7 +11680,7 @@ async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {})
11575
11680
  // says what it intends, then the actions it took appear under it.
11576
11681
  if (!REPL_VERBOSE && stepThought.trim() && !stepHasCode(stepThought)) {
11577
11682
  heldThought = stepThought; // #45: decided by the next line — see releaseHeldThought
11578
- } else if (printThoughtBlock(stepThought, onContent)) {
11683
+ } else if (printThoughtBlock(stepThought, onContent, { gap: !lastWasBlank })) {
11579
11684
  lastWasBlank = false;
11580
11685
  }
11581
11686
  stepThought = "";
@@ -12274,6 +12379,7 @@ async function main() {
12274
12379
  // private (task #13); empty Enter declines to the private default.
12275
12380
  const { DEFAULT_API_BASE } = await import("./device-login.mjs");
12276
12381
  const pick1 = await promptForLicenseToken(ask, licenseToken1, {
12382
+ askSecret,
12277
12383
  validateOpts: {
12278
12384
  hostedBase: String(process.env.GU_TOKEN_API_BASE ?? "").trim() || DEFAULT_API_BASE,
12279
12385
  },
@@ -12503,6 +12609,7 @@ async function main() {
12503
12609
  // the local setup answers — OR them in, never clear a local "missing" on a maybe, because
12504
12610
  // private mode's local API can itself be unconfigured.
12505
12611
  if (p.agentMissing === true) agentMissing = true;
12612
+ noModelAtAll = nothingToAnswerWith(p);
12506
12613
  if (p.ragEnabled !== true) embedMissing = true;
12507
12614
  // A RESTORED PICK MUST STILL EXIST. The stored id names a model on the box this folder was
12508
12615
  // last pointed at — and the box can change (`/model` → change the Ollama server), a model
@@ -12596,7 +12703,14 @@ async function main() {
12596
12703
  // Task #136/#154: mode changes WHERE things run — too important to leave implicit.
12597
12704
  // The precedence lives in modeRow() with its tests; see the note there.
12598
12705
  ...(() => {
12599
- const m = modeRow({ clientExpert: isClientExpert(), clientMode: clientModeBanner });
12706
+ const m = modeRow({
12707
+ clientExpert: isClientExpert(),
12708
+ clientMode: clientModeBanner,
12709
+ modelUrls: [p.agentBaseURL],
12710
+ // The coder IN USE in this folder, not the account default (the raw payload field
12711
+ // is the default only — key-choice-regression.test.mjs).
12712
+ coderWhere: activeCoderWhere,
12713
+ });
12600
12714
  if (!m) return [];
12601
12715
  const name = m.label === "client expert" ? accent(m.label + " ") : m.label + " ";
12602
12716
  return [lbl("mode") + name + dim(m.detail)];
@@ -12693,6 +12807,8 @@ async function main() {
12693
12807
  confirm: (q, defYes) => askYesNo(q, defYes),
12694
12808
  // A client installs ONLY the framework — never mlx-lm/hf.
12695
12809
  modelHost: clientModeBanner ? false : undefined,
12810
+ // The yes two lines up IS the answer — do not ask the same thing again (#333).
12811
+ agreed: ["agentLibs"],
12696
12812
  });
12697
12813
  // The worker reads worker.env ONCE at startup, so a daemon that is already
12698
12814
  // running still has the OLD python — the packages would look "not installed"
@@ -13294,6 +13410,7 @@ async function main() {
13294
13410
  if (line === "/agent-model") {
13295
13411
  await chooseAgentModel();
13296
13412
  agentMissing = false; // Task #14: an agent model was just chosen → clear its warning
13413
+ setModelFallback(null); // #343: so is the stand-in a fallback warning named
13297
13414
  continue;
13298
13415
  }
13299
13416
  if (line === "/embed-model") {
@@ -13645,6 +13762,7 @@ async function main() {
13645
13762
  // #323: priced by the coder this turn STARTS on — a /model switch afterwards must not put
13646
13763
  // another model's price on it.
13647
13764
  const turnCoder = { url: activeCodingUrl(), model: activeCodingModelId() };
13765
+ turnFallback = null; // #343: nothing has fallen back in THIS turn yet
13648
13766
  if (reattach) console.log(GUTTER + dim("following the turn that was running when the connection went…\n"));
13649
13767
  const turn = await runAgentTurn(history, reattach ? { attachJobId: reattach.jobId, ownJob: true } : {});
13650
13768
  // NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
@@ -13659,6 +13777,15 @@ async function main() {
13659
13777
  // Detached: the agent keeps running on the worker, so the question is neither answered
13660
13778
  // nor stopped — leave it untagged, and say nothing contradicting "the job keeps running".
13661
13779
  if (detached) continue;
13780
+ // #343: the bar after this turn — a fallback reported in it stays; an answered turn with
13781
+ // none means the model works again and the warning goes; a failed or stopped turn proves
13782
+ // nothing and leaves it.
13783
+ const fellBack = turnFallback;
13784
+ setModelFallback(fallbackAfterTurn({
13785
+ current: modelFallback,
13786
+ reportedThisTurn: fellBack,
13787
+ answered: Boolean(answer) && !answer.startsWith(TURN_FAILED_PREFIX),
13788
+ }));
13662
13789
  // Feed the bottom bar's token row (task #104). Assigned for EVERY turn — including one
13663
13790
  // that ended without an answer — so the row never shows a stale count from an older
13664
13791
  // turn. It replaces the print-once-per-turn footer that used to live here: the bar is
@@ -13688,6 +13815,7 @@ async function main() {
13688
13815
  failures: noteTaskFailure(taskLine, classifyTurnFailure(_failText)),
13689
13816
  authored: true,
13690
13817
  });
13818
+ if (fellBack) printFallbackLine(fellBack);
13691
13819
  } else if (answer) {
13692
13820
  noteTaskSuccess();
13693
13821
  history.push({ role: "assistant", content: answer });
@@ -13731,6 +13859,9 @@ async function main() {
13731
13859
  // builder, so a change to the block's shape can no longer leave this behind.
13732
13860
  registerCopyBlock(block.rows, plain, 1);
13733
13861
  }
13862
+ // #343: the turn's own record of the fallback, under its answer — the bar's row can be
13863
+ // gone by the next turn, and the scrollback is where "which model wrote this?" is asked.
13864
+ if (fellBack) printFallbackLine(fellBack);
13734
13865
  } else {
13735
13866
  // A turn that ended WITHOUT an answer (Ctrl+C cancel, or an abort/failure) must not
13736
13867
  // silently launch another turn. Any keystrokes the user typed DURING the dead turn
@@ -13780,6 +13911,12 @@ async function main() {
13780
13911
  process.exit(0);
13781
13912
  }
13782
13913
 
13914
+ /** One yellow line in the transcript saying which model stood in for which (task #343). */
13915
+ function printFallbackLine(fb) {
13916
+ const rows = wrapBlock(fallbackHistoryLine(fb), GUTTER, process.stdout.columns || 80);
13917
+ console.log(rows.map((r) => GUTTER + yellow(r)).join("\n") + "\n");
13918
+ }
13919
+
13783
13920
  /**
13784
13921
  * Give the screen back to the shell with its prompt RIGHT UNDER the last output (#314). unpinBar()
13785
13922
  * parks the caret at the zone's top row, which is where the shell then printed its prompt: seven