@tiens.nguyen/gu-cli 1.0.792 → 1.0.794
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clear-state.mjs +4 -0
- package/cli-token.mjs +25 -2
- package/client-expert-api.mjs +249 -6
- package/client-expert-run.mjs +39 -4
- package/client-expert-setup.mjs +21 -1
- package/first-run-steps.mjs +26 -0
- package/gonext_agent_chat.py +124 -0
- package/gu-cli.mjs +38 -0
- package/gu-repl.mjs +154 -17
- package/install-path.mjs +23 -1
- package/model-doctor.mjs +33 -4
- package/model-fallback.mjs +127 -0
- package/node-version.mjs +50 -2
- package/package.json +2 -1
- package/platform-tools.mjs +49 -3
- package/terminal-layout.mjs +46 -2
package/gonext_agent_chat.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3379
|
-
//
|
|
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
|
-
|
|
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
|
|
11439
|
-
//
|
|
11440
|
-
//
|
|
11441
|
-
//
|
|
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
|
-
|
|
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({
|
|
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
|