@tiens.nguyen/gu-cli 1.0.768 → 1.0.770
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/agent-stop.mjs +22 -5
- package/client-expert-api.mjs +89 -8
- package/client-expert-run.mjs +104 -12
- package/gonext_agent_chat.py +217 -11
- package/gu-cli.mjs +56 -29
- package/gu-repl.mjs +311 -31
- package/image-paste.mjs +10 -0
- package/model-setup-ui.mjs +114 -0
- package/ollama-setup.mjs +128 -23
- package/package.json +5 -1
- package/setup-prompts.mjs +82 -0
- package/terminal-layout.mjs +31 -1
- package/turn-stop.mjs +78 -0
- package/workspace-revert.mjs +106 -0
package/gu-repl.mjs
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
|
|
27
27
|
import { readStepTokens } from "./step-tokens.mjs";
|
|
28
|
+
import { snapshotInput, restoreInput, replaceStoppedQuestion, stopNoteLines } from "./turn-stop.mjs";
|
|
28
29
|
import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
|
|
29
30
|
import { spawn } from "node:child_process";
|
|
30
31
|
// FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
|
|
@@ -96,6 +97,7 @@ import {
|
|
|
96
97
|
physicalRows,
|
|
97
98
|
inputPhysicalRows,
|
|
98
99
|
buildFooterRows,
|
|
100
|
+
pasteHint,
|
|
99
101
|
footerDrawSeq,
|
|
100
102
|
footerEraseSeq,
|
|
101
103
|
clearBlockSeq,
|
|
@@ -632,6 +634,10 @@ function reloadWorkerEnv() {
|
|
|
632
634
|
// first-run branch also runs one — without this the same walkthrough printed twice in a single
|
|
633
635
|
// run, MongoDB instructions and all. Reported live on Windows 11.
|
|
634
636
|
let clientExpertRanThisProcess = false;
|
|
637
|
+
// #301: setup chose "Load a saved preset" — main() opens /load-presets before the first prompt.
|
|
638
|
+
// Declared HERE, above the first-run block: that block runs at module top level, before any
|
|
639
|
+
// declaration further down exists.
|
|
640
|
+
let openPresetsAtStart = false;
|
|
635
641
|
|
|
636
642
|
if (!workerKey || !apiBase) {
|
|
637
643
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
@@ -728,7 +734,12 @@ if (!workerKey || !apiBase) {
|
|
|
728
734
|
await saveState({ ...(await loadState()), chosenMode: mode0 ?? "client" });
|
|
729
735
|
if (mode0 === "client-expert") {
|
|
730
736
|
clientExpertRanThisProcess = true;
|
|
731
|
-
|
|
737
|
+
// #301/#300: Esc-aware and masked readers on the same rl0 — the model questions offer
|
|
738
|
+
// Esc, and a key or a password must not echo.
|
|
739
|
+
const { setupPrompts } = await import("./setup-prompts.mjs");
|
|
740
|
+
const sp = setupPrompts(rl0, { style: accent });
|
|
741
|
+
const setup = await runClientExpertSetup({ ask: ask0, askStep: sp.askStep, askSecret: sp.askSecret });
|
|
742
|
+
if (setup.loadPreset) openPresetsAtStart = true;
|
|
732
743
|
// STOP when setup cannot go further.
|
|
733
744
|
//
|
|
734
745
|
// This used to fall through to the hosted sign-in, on the theory that "this machine
|
|
@@ -857,6 +868,7 @@ const COMMANDS = [
|
|
|
857
868
|
// they worked, Tab-completed to nothing, and appeared in no help. Aliases is what the registry
|
|
858
869
|
// already has for exactly this — /quit, /rag-local.
|
|
859
870
|
{ name: "/host-mode", desc: "undo client mode — run agent turns on THIS machine again" },
|
|
871
|
+
{ name: "/undo", usage: "/undo [runId]", desc: "put back the files the last stopped turn changed (from its backups) — asks first" },
|
|
860
872
|
{ name: "/server", desc: "pick a deployment server (host/user) for this session" },
|
|
861
873
|
{ name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
|
|
862
874
|
{ name: "/s3", usage: "/s3 [test]", desc: "set up YOUR S3 bucket (RAG index, and the PDFs/downloads gu makes) and check the AWS keys work — without it, files are saved on this computer" },
|
|
@@ -1495,6 +1507,14 @@ const stripAnsiEscapes = (s) =>
|
|
|
1495
1507
|
const PASTE_MIN_CHARS = 200;
|
|
1496
1508
|
const PASTE_MIN_LINES = 3;
|
|
1497
1509
|
const pasteStore = new Map(); // token -> the real text
|
|
1510
|
+
// Task #302: the last line as TYPED — placeholders and all — taken before it is expanded for the
|
|
1511
|
+
// model, so a turn stopped with Ctrl+C can give the question back for editing.
|
|
1512
|
+
let lastSubmittedInput = null;
|
|
1513
|
+
// Task #302: set while the input holds a stopped turn's question; the next line sent from it
|
|
1514
|
+
// REPLACES that question in history when it was edited. Cleared by "never mind" (Ctrl+C).
|
|
1515
|
+
let prefillFor = null;
|
|
1516
|
+
// Task #302: the backup run of the last stopped turn — what /undo reverts by default.
|
|
1517
|
+
let lastStoppedRun = null;
|
|
1498
1518
|
// ---------- pasted images (task #284) ----------
|
|
1499
1519
|
const imageStore = createImageStore();
|
|
1500
1520
|
// Shown on the bottom row after a refused or failed image paste; clears itself.
|
|
@@ -1609,6 +1629,23 @@ const isNetworkError = (e) =>
|
|
|
1609
1629
|
/fetch failed|network|ECONN|ETIMEDOUT|EAI_AGAIN|socket hang up|und_err|terminated/i.test(
|
|
1610
1630
|
String(e?.message ?? e)
|
|
1611
1631
|
);
|
|
1632
|
+
/** Is the API on this machine? Then "unreachable" means the API process, not the network (#302). */
|
|
1633
|
+
const apiIsLocal = () => {
|
|
1634
|
+
try {
|
|
1635
|
+
const h = new URL(apiBase).hostname;
|
|
1636
|
+
return h === "localhost" || h === "::1" || h === "[::1]" || h.startsWith("127.");
|
|
1637
|
+
} catch {
|
|
1638
|
+
return false;
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
/** How long a turn waits for the network (GONEXT_OFFLINE_WAIT_S, default 10 min) — the agent's own budget. */
|
|
1642
|
+
const offlineWaitMs = () => {
|
|
1643
|
+
const v = Number(process.env.GONEXT_OFFLINE_WAIT_S);
|
|
1644
|
+
return (Number.isFinite(v) && v >= 0 ? v : 600) * 1000;
|
|
1645
|
+
};
|
|
1646
|
+
// #302 part 3: a turn this terminal lost contact with (the network dropped) — its job may still be
|
|
1647
|
+
// running, so "continue" follows it again instead of asking the question a second time.
|
|
1648
|
+
let lostTurn = null;
|
|
1612
1649
|
/**
|
|
1613
1650
|
* Did OUR OWN deadline fire, rather than the connection failing? → boolean.
|
|
1614
1651
|
*
|
|
@@ -1721,6 +1758,10 @@ rl.on("line", (l) => {
|
|
|
1721
1758
|
// user pasted.
|
|
1722
1759
|
// Images too (task #284): "[Image #1]" goes out as "[Image #1: <file>]" for the agent to
|
|
1723
1760
|
// attach; the next message numbers its images from #1 again.
|
|
1761
|
+
lastSubmittedInput = snapshotInput(stripAnsiEscapes(submitted), {
|
|
1762
|
+
pastes: pasteStore,
|
|
1763
|
+
images: new Map(imageStore.entries()),
|
|
1764
|
+
});
|
|
1724
1765
|
const clean = imageStore.expand(expandPasteTokens(stripAnsiEscapes(submitted)));
|
|
1725
1766
|
imageStore.clear();
|
|
1726
1767
|
const waiter = _lineWaiters.pop(); // innermost reader first — see _lineWaiters
|
|
@@ -2376,6 +2417,9 @@ const footerRows = () =>
|
|
|
2376
2417
|
identity: identityLineText(),
|
|
2377
2418
|
token: tokenLineText(),
|
|
2378
2419
|
mode: modeLineText(),
|
|
2420
|
+
// How to paste here (issue #67): the platform's text key, plus gu's image key when the coder
|
|
2421
|
+
// can take an image.
|
|
2422
|
+
modeHint: pasteHint({ images: coderTakesImages() }),
|
|
2379
2423
|
// Its own row (#54), sized to the window so the fix it names is never the part cut off.
|
|
2380
2424
|
notice: imageNotice
|
|
2381
2425
|
? orange("⚠ " + fitImageNotice(imageNotice, (process.stdout.columns || 80) - visibleLen(GUTTER) - 1))
|
|
@@ -5231,15 +5275,35 @@ async function reconfigureOllamaServer() {
|
|
|
5231
5275
|
|
|
5232
5276
|
/** Load a box's models THROUGH THE PROXY (#160) — a bare fetch is what fails on a restricted
|
|
5233
5277
|
* network, and it fails as "no models", which is the wrong story entirely. */
|
|
5234
|
-
const load = async (n) => {
|
|
5278
|
+
const load = async (n, key = "") => {
|
|
5235
5279
|
const res = await withPendingStatus(`loading models from ${n.host}`, () =>
|
|
5236
|
-
proxyFetch(os.tagsUrlFor(n), {
|
|
5280
|
+
proxyFetch(os.tagsUrlFor(n), {
|
|
5281
|
+
signal: AbortSignal.timeout(8000),
|
|
5282
|
+
// #301: the key typed for this server; absent → the request is what it always was.
|
|
5283
|
+
...(key ? { headers: { Authorization: `Bearer ${key}` } } : {}),
|
|
5284
|
+
})
|
|
5237
5285
|
);
|
|
5238
5286
|
// The status is kept: a 404 here means "answered, but not an Ollama" (#43), not "unreachable".
|
|
5239
5287
|
if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { httpStatus: res.status });
|
|
5240
5288
|
return am.parseOllamaTags(await res.json().catch(() => null));
|
|
5241
5289
|
};
|
|
5242
5290
|
|
|
5291
|
+
// #301: the way out, said under every server question (full once, short after), and the keys
|
|
5292
|
+
// typed for servers, by host — saved against their rows once the settings are written.
|
|
5293
|
+
const ui = await import("./model-setup-ui.mjs");
|
|
5294
|
+
const hint = ui.createHinter();
|
|
5295
|
+
const typedKeys = new Map();
|
|
5296
|
+
const askKeyFor = async (host) => {
|
|
5297
|
+
if (typedKeys.has(host)) return typedKeys.get(host); // same server for both roles → asked once
|
|
5298
|
+
console.log(dim(` ${hint()}`));
|
|
5299
|
+
// askSecret: masked, kept out of history; Esc throws PromptCancelled → /model is cancelled
|
|
5300
|
+
// with nothing changed, which is what "skip all" means here.
|
|
5301
|
+
const k = (await askSecret(` API key for ${host} (Enter for none): `)).trim();
|
|
5302
|
+
typedKeys.set(host, k);
|
|
5303
|
+
console.log(GUTTER + dim(ui.keyConfirmation(host, k)));
|
|
5304
|
+
return k;
|
|
5305
|
+
};
|
|
5306
|
+
|
|
5243
5307
|
/** Ask for one server and load it. Returns {normalized, models} or null (declined/failed). */
|
|
5244
5308
|
const askServer = async (label, dflt, { require = false, short = "server", role = "coder" } = {}) => {
|
|
5245
5309
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -5251,13 +5315,15 @@ async function reconfigureOllamaServer() {
|
|
|
5251
5315
|
// — normalizeOllamaUrl adds it — and displayUrl round-trips, so what is shown can be fed
|
|
5252
5316
|
// straight back as the default.
|
|
5253
5317
|
console.log(dim(` ${label} — Enter keeps ${os.displayUrl(dflt)}`));
|
|
5318
|
+
console.log(dim(` ${hint()}`));
|
|
5254
5319
|
// THE ROW MUST SAY WHICH SERVER IT IS ASKING FOR. It said a bare "server:" — chosen to
|
|
5255
5320
|
// fit the frozen row, which traded away the one thing the label was for. Two questions
|
|
5256
5321
|
// in a row, both reading "server:", and the reader cannot tell which one they are
|
|
5257
5322
|
// answering (reported: "it should say 'Agent + Embedder server' then 'Coding Server',
|
|
5258
5323
|
// it only says 'server:'"). `short` is the role in the fewest words that still name it.
|
|
5259
5324
|
const typed = (await ask(` ${short}: `)).trim();
|
|
5260
|
-
|
|
5325
|
+
// "s" is the advertised skip (#301); the older words still work.
|
|
5326
|
+
if (ui.readStepAnswer(typed).kind === "skip") return null;
|
|
5261
5327
|
// EVERY READING OF A BARE HOSTNAME, in order. Typing "ollama2.gomarsic.cc" means
|
|
5262
5328
|
// https://…:443 behind a proxy, while "192.168.1.40" means http://…:11434 — one guess
|
|
5263
5329
|
// is right for one of them and produces a timeout that looks like a dead server for the
|
|
@@ -5270,15 +5336,33 @@ async function reconfigureOllamaServer() {
|
|
|
5270
5336
|
let n = null;
|
|
5271
5337
|
let models = null;
|
|
5272
5338
|
let lastErr = null;
|
|
5273
|
-
|
|
5274
|
-
|
|
5275
|
-
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5339
|
+
// #301: a TYPED public address may need a key — asked before listing, so a server that
|
|
5340
|
+
// needs one can list its models. A LAN box is never asked.
|
|
5341
|
+
let key = "";
|
|
5342
|
+
if (ui.shouldAskKey({ typed, host: candidates[0].host })) key = await askKeyFor(candidates[0].host);
|
|
5343
|
+
const tryAll = async () => {
|
|
5344
|
+
for (const cand of candidates) {
|
|
5345
|
+
try {
|
|
5346
|
+
models = await load(cand, key);
|
|
5347
|
+
n = cand;
|
|
5348
|
+
if (candidates.length > 1) console.log(dim(` using ${os.displayUrl(cand.url)}`));
|
|
5349
|
+
return;
|
|
5350
|
+
} catch (err) {
|
|
5351
|
+
lastErr = err;
|
|
5352
|
+
}
|
|
5281
5353
|
}
|
|
5354
|
+
};
|
|
5355
|
+
await tryAll();
|
|
5356
|
+
// Refused for want of credentials: ask now, whatever the address looked like, and list again.
|
|
5357
|
+
if (!n && ui.needsKeyAfterStatus(lastErr?.httpStatus) && !key) {
|
|
5358
|
+
console.log(yellow(` ${candidates[0].host} needs an API key to list its models.`));
|
|
5359
|
+
typedKeys.delete(candidates[0].host);
|
|
5360
|
+
key = await askKeyFor(candidates[0].host);
|
|
5361
|
+
if (key) await tryAll();
|
|
5362
|
+
}
|
|
5363
|
+
if (!n && ui.needsKeyAfterStatus(lastErr?.httpStatus)) {
|
|
5364
|
+
console.log(red(` ${candidates[0].host} refused the request (HTTP ${lastErr.httpStatus})${key ? " — that key was not accepted" : ""}.`));
|
|
5365
|
+
continue;
|
|
5282
5366
|
}
|
|
5283
5367
|
if (!n) {
|
|
5284
5368
|
if (lastErr?.httpStatus === 404) {
|
|
@@ -5305,7 +5389,7 @@ async function reconfigureOllamaServer() {
|
|
|
5305
5389
|
const need = os.checkRequired(models);
|
|
5306
5390
|
if (!need.ok) {
|
|
5307
5391
|
for (const l of os.describeNotReady(n.host, need.missing)) console.log(yellow(` ${l}`));
|
|
5308
|
-
console.log(dim(" (or type
|
|
5392
|
+
console.log(dim(" (or type s to skip — you can set it later with /model)"));
|
|
5309
5393
|
continue;
|
|
5310
5394
|
}
|
|
5311
5395
|
console.log(dim(` ✓ ${n.host} has the ${os.REQUIRED_AGENT_PREFIX} family and the embedder`));
|
|
@@ -5328,10 +5412,10 @@ async function reconfigureOllamaServer() {
|
|
|
5328
5412
|
if (agentOnProvider) {
|
|
5329
5413
|
const host = hostOf(String(settings.agentModelUrl ?? ""));
|
|
5330
5414
|
console.log(dim(` The agent model is on ${host} (OpenAI-compatible) — kept as it is; change it with /agent-model.`));
|
|
5331
|
-
console.log(dim(" Enter keeps the current address
|
|
5415
|
+
console.log(dim(" Enter keeps the current address."));
|
|
5332
5416
|
} else {
|
|
5333
5417
|
console.log(dim(" TWO servers may be used: the agent + embedder on one, the coder on another."));
|
|
5334
|
-
console.log(dim(" Enter keeps the current address
|
|
5418
|
+
console.log(dim(" Enter keeps the current address."));
|
|
5335
5419
|
agent = await askServer("Agent + embedding server", curAgent, {
|
|
5336
5420
|
require: true,
|
|
5337
5421
|
short: "agent + embed server",
|
|
@@ -5476,6 +5560,32 @@ async function reconfigureOllamaServer() {
|
|
|
5476
5560
|
console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
|
|
5477
5561
|
return;
|
|
5478
5562
|
}
|
|
5563
|
+
// #301: THE KEYS TYPED FOR THE SERVERS, bound to their rows now that the rows exist (the API
|
|
5564
|
+
// refuses a key for a row it does not have). Before the cloud check below, so a key typed for
|
|
5565
|
+
// the Ollama cloud is not asked for a second time. Reported by host, never by value.
|
|
5566
|
+
{
|
|
5567
|
+
const used = [agent?.normalized?.url, coder?.normalized?.url].filter(Boolean);
|
|
5568
|
+
// By HOSTNAME: the key was asked for urlCandidates' host, which carries no port, while a URL's
|
|
5569
|
+
// .host does — box.example.com:8443 would otherwise never match its own key.
|
|
5570
|
+
const hostnameOf = (u) => { try { return new URL(u).hostname; } catch { return ""; } };
|
|
5571
|
+
const pending = [...typedKeys].filter(([host, k]) => k && used.some((u) => hostnameOf(u) === host));
|
|
5572
|
+
if (pending.length) {
|
|
5573
|
+
const fresh = await withPendingStatus("storing the key", fetchWorkerSettings);
|
|
5574
|
+
const ps = await import("./coding-provider-setup.mjs");
|
|
5575
|
+
for (const [host, k] of pending) {
|
|
5576
|
+
const url = used.find((u) => hostnameOf(u) === host);
|
|
5577
|
+
const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), url);
|
|
5578
|
+
if (!found) {
|
|
5579
|
+
console.log(yellow(` no server row holds ${host} yet — add its key with ctrl+k in /model.`));
|
|
5580
|
+
continue;
|
|
5581
|
+
}
|
|
5582
|
+
const ok = await withPendingStatus("saving the key", () => addCodingKey(host, k, found.id));
|
|
5583
|
+
console.log(ok
|
|
5584
|
+
? GUTTER + dim(`✓ key stored for ${host} — the server's default key`)
|
|
5585
|
+
: red(` couldn't store the key for ${host} — add it with ctrl+k.`));
|
|
5586
|
+
}
|
|
5587
|
+
}
|
|
5588
|
+
}
|
|
5479
5589
|
// THE OLLAMA CLOUD NEEDS ITS OWN KEY. Reported: choosing https://ollama.com here listed its 20 models
|
|
5480
5590
|
// (that list is public) and saved, without a key — and the next turn failed "REJECTED the coding API
|
|
5481
5591
|
// key", having borrowed an unrelated sk-… key. Asked for now, bound to the row just saved.
|
|
@@ -6567,6 +6677,103 @@ async function runSavePresetCommand(line) {
|
|
|
6567
6677
|
* key is saved under the preset's name and made that row's default, and the embedder is set. The
|
|
6568
6678
|
* user reconfirms before anything changes, like /load-settings.
|
|
6569
6679
|
*/
|
|
6680
|
+
/**
|
|
6681
|
+
* After Ctrl+C stopped THIS terminal's turn (task #302): say what it left behind, and give the
|
|
6682
|
+
* question back.
|
|
6683
|
+
*
|
|
6684
|
+
* · the command it was in the middle of, if any — that one cannot be made safe, only named
|
|
6685
|
+
* · the files it changed (its backup run's manifest) — kept as they are; /undo offers to put
|
|
6686
|
+
* them back, because part of the work may be wanted and nothing is reverted by itself
|
|
6687
|
+
* · the question, exactly as typed (placeholders and all), back in the input — edit it and
|
|
6688
|
+
* press Enter; Ctrl+C clears it. Never sent by itself.
|
|
6689
|
+
*
|
|
6690
|
+
* What the turn printed stays on screen: it is the record of what it did.
|
|
6691
|
+
*/
|
|
6692
|
+
async function afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand = "", typed = "" }) {
|
|
6693
|
+
let files = [];
|
|
6694
|
+
try {
|
|
6695
|
+
const ws = await import("./workspace-revert.mjs");
|
|
6696
|
+
const runId = ws.runSince(turnStartMs);
|
|
6697
|
+
if (runId) {
|
|
6698
|
+
const changes = (await ws.readRunChanges(runId)) ?? [];
|
|
6699
|
+
files = changes.filter((c) => c?.action !== "create_dir").map((c) => c.path);
|
|
6700
|
+
if (files.length) lastStoppedRun = runId;
|
|
6701
|
+
}
|
|
6702
|
+
} catch {
|
|
6703
|
+
/* no backups readable — nothing to name */
|
|
6704
|
+
}
|
|
6705
|
+
// A bare "continue" was the typed line: giving THAT back is no help, so only the note.
|
|
6706
|
+
const canRestore = Boolean(submittedInput?.raw) && !isResumeWord(typed);
|
|
6707
|
+
for (const l of stopNoteLines({ files, command: stoppedCommand, restored: canRestore })) {
|
|
6708
|
+
console.log(GUTTER + dim(l));
|
|
6709
|
+
}
|
|
6710
|
+
console.log("");
|
|
6711
|
+
if (!canRestore) return;
|
|
6712
|
+
const text = restoreInput(submittedInput, { pasteStore, imageStore });
|
|
6713
|
+
rl.line = text;
|
|
6714
|
+
rl.cursor = text.length;
|
|
6715
|
+
rl.historyIndex = -1;
|
|
6716
|
+
prefillFor = { task: taskLine };
|
|
6717
|
+
if (barPinned()) paintBar();
|
|
6718
|
+
}
|
|
6719
|
+
|
|
6720
|
+
/**
|
|
6721
|
+
* /undo — put back what a stopped turn changed (task #302): the last stopped turn's run, or the
|
|
6722
|
+
* run named. Shows exactly what it will do and asks first — a file edited by hand since the turn
|
|
6723
|
+
* would lose those edits, and that is said too.
|
|
6724
|
+
*/
|
|
6725
|
+
async function runUndoCommand(line) {
|
|
6726
|
+
const ws = await import("./workspace-revert.mjs");
|
|
6727
|
+
const named = line.trim().slice("/undo".length).trim();
|
|
6728
|
+
const runId = named || lastStoppedRun;
|
|
6729
|
+
console.log("");
|
|
6730
|
+
if (!runId) {
|
|
6731
|
+
console.log(GUTTER + dim("nothing to undo — /undo puts back what a turn you stopped with Ctrl+C changed."));
|
|
6732
|
+
console.log(GUTTER + dim(`(older runs: ${ws.BACKUPS_ROOT} — /undo <run>)\n`));
|
|
6733
|
+
return;
|
|
6734
|
+
}
|
|
6735
|
+
const changes = await ws.readRunChanges(runId);
|
|
6736
|
+
if (!changes) {
|
|
6737
|
+
console.log(GUTTER + red(`no backups for run ${runId}.\n`));
|
|
6738
|
+
return;
|
|
6739
|
+
}
|
|
6740
|
+
const steps = ws.planRevert(changes);
|
|
6741
|
+
if (!steps.length) {
|
|
6742
|
+
console.log(GUTTER + dim("that turn changed no files.\n"));
|
|
6743
|
+
return;
|
|
6744
|
+
}
|
|
6745
|
+
let manifestAt = 0;
|
|
6746
|
+
try {
|
|
6747
|
+
manifestAt = statSync(join(ws.BACKUPS_ROOT, runId, "manifest.json")).mtimeMs;
|
|
6748
|
+
} catch {
|
|
6749
|
+
/* unknown — no "edited since" warnings */
|
|
6750
|
+
}
|
|
6751
|
+
const verb = { restore: "restore", delete: "delete ", rmdir: "remove " };
|
|
6752
|
+
for (const st of steps) {
|
|
6753
|
+
let since = "";
|
|
6754
|
+
try {
|
|
6755
|
+
if (manifestAt && st.op !== "rmdir" && statSync(st.path).mtimeMs > manifestAt + 1000) {
|
|
6756
|
+
since = yellow(" (changed since — those edits would be lost)");
|
|
6757
|
+
}
|
|
6758
|
+
} catch {
|
|
6759
|
+
/* gone already */
|
|
6760
|
+
}
|
|
6761
|
+
console.log(GUTTER + `${verb[st.op]} ${st.path}` + since);
|
|
6762
|
+
}
|
|
6763
|
+
if (!(await askYesNo("Put these back?", false))) {
|
|
6764
|
+
console.log(GUTTER + dim("(nothing changed)\n"));
|
|
6765
|
+
return;
|
|
6766
|
+
}
|
|
6767
|
+
const r = await ws.applyRevert(steps);
|
|
6768
|
+
for (const l of r.lines) console.log(GUTTER + dim(l));
|
|
6769
|
+
console.log(
|
|
6770
|
+
GUTTER + (r.failed ? yellow : dim)(
|
|
6771
|
+
`${r.restored} restored, ${r.deleted} removed${r.failed ? `, ${r.failed} failed` : ""} — the backups are kept.`
|
|
6772
|
+
) + "\n"
|
|
6773
|
+
);
|
|
6774
|
+
if (runId === lastStoppedRun) lastStoppedRun = null;
|
|
6775
|
+
}
|
|
6776
|
+
|
|
6570
6777
|
async function runLoadPresetsCommand(line) {
|
|
6571
6778
|
const pr = await import("./presets-transfer.mjs");
|
|
6572
6779
|
const home = homedir();
|
|
@@ -9472,24 +9679,29 @@ async function ensureTokenForTurn() {
|
|
|
9472
9679
|
return false;
|
|
9473
9680
|
}
|
|
9474
9681
|
|
|
9475
|
-
async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
9682
|
+
async function runAgentTurn(history, { attachJobId = "", ownJob = false } = {}) {
|
|
9476
9683
|
// #238: when the licence is ENFORCED and this account is not entitled, no turn is sent. Decided at
|
|
9477
9684
|
// startup (see licenceBlock) — never re-checked here, so a turn cannot die halfway through.
|
|
9478
9685
|
if (licenceBlock) {
|
|
9479
9686
|
console.log(GUTTER + orange("Not sent.") + dim(" " + licenceBlock) + "\n");
|
|
9480
9687
|
return;
|
|
9481
9688
|
}
|
|
9482
|
-
|
|
9689
|
+
// TWO THINGS "attaching" used to mean at once (task #302, part 3): the question is NOT sent
|
|
9690
|
+
// again (skipAsk), and the job is SOMEONE ELSE'S (attached — Ctrl+C only stops watching, its
|
|
9691
|
+
// tokens are theirs to count). Re-attaching to this terminal's OWN turn after a lost connection
|
|
9692
|
+
// is the first without the second: Ctrl+C there is a real cancel.
|
|
9693
|
+
const skipAsk = Boolean(attachJobId);
|
|
9694
|
+
const attached = skipAsk && !ownJob;
|
|
9483
9695
|
// PRIVATE MODE NEEDS THE gu TOKEN (task #246): the local API fetches the agent's instructions
|
|
9484
9696
|
// from the gu service with it. Asked for HERE, before anything is sent, with the steps — rather
|
|
9485
9697
|
// than letting the turn fail and leaving the user to decode why.
|
|
9486
|
-
if (!
|
|
9698
|
+
if (!skipAsk && !(await ensureTokenForTurn())) return;
|
|
9487
9699
|
inputVisible = false; // the frozen zone shows an empty prompt for the duration (#141)
|
|
9488
9700
|
// Something on screen from the FIRST moment (task #140): Enter has just wiped the bar, and
|
|
9489
9701
|
// the next thing to draw itself is the turn's ticker — which stays silent until the daemon
|
|
9490
9702
|
// claims the job, seconds away. This names what is actually happening meanwhile.
|
|
9491
|
-
startPendingStatus(
|
|
9492
|
-
const res =
|
|
9703
|
+
startPendingStatus(skipAsk ? "attaching" : "sending");
|
|
9704
|
+
const res = skipAsk ? null : await fetch(`${apiBase}/api/worker/agent-ask`, {
|
|
9493
9705
|
method: "POST",
|
|
9494
9706
|
headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
|
|
9495
9707
|
// cwd = this terminal's folder → agent puts download/unzip output here (when it's
|
|
@@ -9549,7 +9761,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9549
9761
|
}
|
|
9550
9762
|
throw new Error(data?.error || `agent-ask failed (HTTP ${res.status})`);
|
|
9551
9763
|
}
|
|
9552
|
-
const jobId =
|
|
9764
|
+
const jobId = skipAsk ? attachJobId : data?.jobId;
|
|
9553
9765
|
if (!jobId) throw new Error("agent-ask returned no jobId.");
|
|
9554
9766
|
|
|
9555
9767
|
// THE CODER WAS DROPPED, AND NOTHING SAID SO (task #222 phase 0). When the picked coding
|
|
@@ -9561,7 +9773,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9561
9773
|
// Read off codingKeyMissingKnown, never off `data`: the flag lives under `payload` and this
|
|
9562
9774
|
// response is a different shape, so reaching for it here would be undefined — falsy — and
|
|
9563
9775
|
// warn nobody, which is the bug agent-payload-shape.test.mjs exists to catch.
|
|
9564
|
-
if (!
|
|
9776
|
+
if (!skipAsk && codingKeyMissingKnown === true) {
|
|
9565
9777
|
console.log(
|
|
9566
9778
|
GUTTER + orange("⚠ no API key for the coding model") +
|
|
9567
9779
|
dim(" — coding is running on your chat model. ctrl+k to set it.\n")
|
|
@@ -9573,7 +9785,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9573
9785
|
// hand-over to the turn's own ticker happens the moment that worker claims it. An ATTACHED
|
|
9574
9786
|
// job is usually already running, so it says what is true of it instead — the poll below
|
|
9575
9787
|
// corrects either of them within a second.
|
|
9576
|
-
setPendingStatus(
|
|
9788
|
+
setPendingStatus(skipAsk ? "attaching — reading its progress" : "queued — waiting for the worker");
|
|
9577
9789
|
following = true;
|
|
9578
9790
|
followingAttached = attached;
|
|
9579
9791
|
typedDuringTurn = false;
|
|
@@ -9615,6 +9827,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
9615
9827
|
// key lookup failed." — a cold Mongo connection, NOT a bad key) must NOT kill a
|
|
9616
9828
|
// long-running turn. Tolerate a run of them; only give up after too many in a row.
|
|
9617
9829
|
let pollFailures = 0;
|
|
9830
|
+
let pollOfflineSince = 0; // #302 part 3: when a remote API stopped answering (0 = reachable)
|
|
9618
9831
|
const MAX_POLL_FAILURES = 6; // ~6 tries with backoff before conceding the follow
|
|
9619
9832
|
let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
|
|
9620
9833
|
// Sticky: the agent RAN, so the coding model was called ("thinking…" was on screen). A
|
|
@@ -10455,6 +10668,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
10455
10668
|
if (jr.ok) {
|
|
10456
10669
|
job = parsed;
|
|
10457
10670
|
pollFailures = 0; // a clean poll resets the tolerance window
|
|
10671
|
+
pollOfflineSince = 0;
|
|
10458
10672
|
} else if (jr.status >= 400 && jr.status < 500) {
|
|
10459
10673
|
// Client error (bad/expired key, job genuinely gone) — NOT transient, fail fast.
|
|
10460
10674
|
throw new Error(parsed?.error || `job poll failed (HTTP ${jr.status})`);
|
|
@@ -10470,11 +10684,33 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
10470
10684
|
const transient = err?.transient === true || isNetworkError(err);
|
|
10471
10685
|
if (!transient) throw err;
|
|
10472
10686
|
pollFailures += 1;
|
|
10473
|
-
|
|
10687
|
+
// THE NETWORK, NOT THE API (task #302, part 3). A REMOTE API that cannot be reached is
|
|
10688
|
+
// usually this machine being offline — a Wi-Fi drop, a VPN reconnecting — while the turn
|
|
10689
|
+
// itself carries on on its worker. So a network error is waited out for the same budget
|
|
10690
|
+
// the agent gives its own calls, instead of giving up after ~13s. A LOCAL API unreachable
|
|
10691
|
+
// is the API process itself gone (see "Agent killed its own API"), which waiting will not
|
|
10692
|
+
// fix — that keeps the quick give-up.
|
|
10693
|
+
const offlineWait = isNetworkError(err) && !apiIsLocal() ? offlineWaitMs() : 0;
|
|
10694
|
+
if (offlineWait) {
|
|
10695
|
+
if (!pollOfflineSince) pollOfflineSince = Date.now();
|
|
10696
|
+
const waited = Date.now() - pollOfflineSince;
|
|
10697
|
+
if (waited < offlineWait) {
|
|
10698
|
+
setPendingStatus(`reconnecting to the API… (${Math.round(waited / 1000)}s)`);
|
|
10699
|
+
await sleep(Math.min(1000 * pollFailures, 10000)); // backoff, capped at 10s
|
|
10700
|
+
continue;
|
|
10701
|
+
}
|
|
10702
|
+
}
|
|
10703
|
+
if (offlineWait || pollFailures >= MAX_POLL_FAILURES) {
|
|
10474
10704
|
clearStatus();
|
|
10475
|
-
|
|
10476
|
-
|
|
10477
|
-
|
|
10705
|
+
// The job id travels with the error: the turn may well still be running on its worker,
|
|
10706
|
+
// and "continue" should FOLLOW it again rather than ask the same question twice.
|
|
10707
|
+
throw Object.assign(
|
|
10708
|
+
new Error(
|
|
10709
|
+
`lost contact with the API after ${offlineWait ? `${Math.round(offlineWait / 60000)} min` : `${pollFailures} tries`} ` +
|
|
10710
|
+
`(${String(err.message).slice(0, 80)}). The turn may still be running on the worker — ` +
|
|
10711
|
+
"type continue to follow it again."
|
|
10712
|
+
),
|
|
10713
|
+
{ lostJobId: jobId }
|
|
10478
10714
|
);
|
|
10479
10715
|
}
|
|
10480
10716
|
await sleep(Math.min(700 * pollFailures, 3000)); // linear backoff, capped at 3s
|
|
@@ -10705,7 +10941,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
|
|
|
10705
10941
|
// turn (main() drops it from history).
|
|
10706
10942
|
clearStatus();
|
|
10707
10943
|
process.stdout.write("\n" + GUTTER + dim("(cancelled — the agent was stopped)") + "\n");
|
|
10708
|
-
return { text: "", shown: false, cancelled: true, started: reachedModel };
|
|
10944
|
+
return { text: "", shown: false, cancelled: true, started: reachedModel, stoppedCommand: runningCmd || "" };
|
|
10709
10945
|
}
|
|
10710
10946
|
if (job.jobStatus === "failed") {
|
|
10711
10947
|
clearStatus();
|
|
@@ -10869,8 +11105,18 @@ async function main() {
|
|
|
10869
11105
|
// recorded, so once the choice was saved this block was skipped and the plan sat on disk
|
|
10870
11106
|
// untouched. Picking it up here is what makes the sentence true.
|
|
10871
11107
|
const { runClientExpertSetup } = await import("./client-expert-run.mjs");
|
|
10872
|
-
const
|
|
11108
|
+
const { SKIP_ALL } = await import("./model-setup-ui.mjs");
|
|
11109
|
+
// #301: keys (and the MongoDB password) through the REPL's own masked prompt; a cancelled
|
|
11110
|
+
// one means "skip the rest", not a crash of the setup.
|
|
11111
|
+
const toSkipAll = (e) => (e instanceof PromptCancelled ? SKIP_ALL : Promise.reject(e));
|
|
11112
|
+
const setup = await runClientExpertSetup({
|
|
11113
|
+
ask,
|
|
11114
|
+
// Esc at a model question throws PromptCancelled here; in setup it means "skip all".
|
|
11115
|
+
askStep: (q) => ask(q).catch(toSkipAll),
|
|
11116
|
+
askSecret: (q) => askSecret(q).catch(toSkipAll),
|
|
11117
|
+
});
|
|
10873
11118
|
if (setup?.complete) reloadWorkerEnv();
|
|
11119
|
+
if (setup?.loadPreset) openPresetsAtStart = true;
|
|
10874
11120
|
}
|
|
10875
11121
|
} catch {
|
|
10876
11122
|
/* never block the REPL on the mode question — the banner still shows what is configured */
|
|
@@ -11548,6 +11794,7 @@ async function main() {
|
|
|
11548
11794
|
rl.line = "";
|
|
11549
11795
|
rl.cursor = 0;
|
|
11550
11796
|
rl.historyIndex = -1;
|
|
11797
|
+
prefillFor = null; // "never mind" also lets go of a restored question (#302)
|
|
11551
11798
|
if (_lineQueue.length > 0) _lineQueue.length = 0;
|
|
11552
11799
|
prunePasteStore("");
|
|
11553
11800
|
imageStore.clear();
|
|
@@ -11621,6 +11868,17 @@ async function main() {
|
|
|
11621
11868
|
// rows it pushes up are real output rather than half-drawn setup.
|
|
11622
11869
|
syncBarPinning();
|
|
11623
11870
|
|
|
11871
|
+
// #301: setup's "Load a saved preset" — the picker opens now that the terminal exists. The whole
|
|
11872
|
+
// /load-presets flow (the list, the plan, the confirmation, the key) is that command's own.
|
|
11873
|
+
if (openPresetsAtStart) {
|
|
11874
|
+
openPresetsAtStart = false;
|
|
11875
|
+
try {
|
|
11876
|
+
await runLoadPresetsCommand("/load-presets");
|
|
11877
|
+
} catch (e) {
|
|
11878
|
+
console.log(GUTTER + dim(`(could not open the presets: ${e?.message ?? e} — run /load-presets)`));
|
|
11879
|
+
}
|
|
11880
|
+
}
|
|
11881
|
+
|
|
11624
11882
|
for (;;) {
|
|
11625
11883
|
stopPendingStatus(); // nothing from a previous turn may still be animating (task #140)
|
|
11626
11884
|
// The bar and the input line live in the frozen rows at the bottom of the window (task
|
|
@@ -11668,6 +11926,12 @@ async function main() {
|
|
|
11668
11926
|
footerActive = false; // a line arrived from the queue (no Enter keypress) → disarm
|
|
11669
11927
|
if (lineRaw === null) break; // stdin closed
|
|
11670
11928
|
const line = lineRaw.trim();
|
|
11929
|
+
// #302: what was typed for THIS line (before expansion), and whether it was sent from a
|
|
11930
|
+
// stopped turn's restored question.
|
|
11931
|
+
const submittedInput = lastSubmittedInput;
|
|
11932
|
+
lastSubmittedInput = null;
|
|
11933
|
+
const editedFrom = prefillFor?.task ?? "";
|
|
11934
|
+
prefillFor = null;
|
|
11671
11935
|
// readline's echo is suppressed while the input line is frozen, so the submitted question
|
|
11672
11936
|
// is printed here instead — it belongs in the scrollback like every other line. Its marker
|
|
11673
11937
|
// is the GREY one: the cyan ">>" belongs to the live input line alone.
|
|
@@ -11802,6 +12066,10 @@ async function main() {
|
|
|
11802
12066
|
await runS3Command(line);
|
|
11803
12067
|
continue;
|
|
11804
12068
|
}
|
|
12069
|
+
if (line === "/undo" || line.startsWith("/undo ")) {
|
|
12070
|
+
await runUndoCommand(line);
|
|
12071
|
+
continue;
|
|
12072
|
+
}
|
|
11805
12073
|
if (line === "/save-preset" || line.startsWith("/save-preset ")) {
|
|
11806
12074
|
await runSavePresetCommand(line);
|
|
11807
12075
|
continue;
|
|
@@ -12054,6 +12322,10 @@ async function main() {
|
|
|
12054
12322
|
// mid-retry still has something to resume next time.
|
|
12055
12323
|
sessionUnfinishedTask = "";
|
|
12056
12324
|
const { task: taskLine, resumed, nothingToResume } = resumeTask(line, pendingResume);
|
|
12325
|
+
// #302 part 3: "continue" after a lost connection FOLLOWS the job that may still be running,
|
|
12326
|
+
// rather than sending the question again. Anything else typed lets it go.
|
|
12327
|
+
const reattach = lostTurn && isResumeWord(line) && lostTurn.taskLine === taskLine ? lostTurn : null;
|
|
12328
|
+
lostTurn = null;
|
|
12057
12329
|
// "continue" with nothing behind it runs NOTHING (task #201). It used to be sent to the
|
|
12058
12330
|
// model as the literal word, which then got remembered as the unfinished task — so every
|
|
12059
12331
|
// later "continue" retried "continue". Worse than the loop: with no task the model invents
|
|
@@ -12072,6 +12344,9 @@ async function main() {
|
|
|
12072
12344
|
// "where were we?" reached the model with no history and it said nothing had been asked.
|
|
12073
12345
|
// Only the failure NOTICE stays out of history (task #144). A resume, or the same question
|
|
12074
12346
|
// typed again after a failure, is already the last entry and is not pushed a second time.
|
|
12347
|
+
// #302: the restored question, EDITED and sent — a change of mind, so it takes the stopped
|
|
12348
|
+
// one's place instead of standing beside it (then the block below sees it as pending).
|
|
12349
|
+
if (editedFrom) replaceStoppedQuestion(history, editedFrom, taskLine);
|
|
12075
12350
|
if (isPendingQuestion(history, taskLine)) {
|
|
12076
12351
|
delete history[history.length - 1].status; // it is the current task again, not a stopped one
|
|
12077
12352
|
} else {
|
|
@@ -12079,7 +12354,9 @@ async function main() {
|
|
|
12079
12354
|
}
|
|
12080
12355
|
await saveSession(cwd, history, { unfinishedTask: isResumeWord(taskLine) ? "" : taskLine });
|
|
12081
12356
|
try {
|
|
12082
|
-
const
|
|
12357
|
+
const turnStartMs = Date.now();
|
|
12358
|
+
if (reattach) console.log(GUTTER + dim("following the turn that was running when the connection went…\n"));
|
|
12359
|
+
const turn = await runAgentTurn(history, reattach ? { attachJobId: reattach.jobId, ownJob: true } : {});
|
|
12083
12360
|
// NOT SENT (licence block, or the token prompt declined): runAgentTurn already said so.
|
|
12084
12361
|
// Nothing began, and "continue" can send it once the cause is fixed (task #247).
|
|
12085
12362
|
if (!turn) {
|
|
@@ -12088,7 +12365,7 @@ async function main() {
|
|
|
12088
12365
|
continue;
|
|
12089
12366
|
}
|
|
12090
12367
|
const { text: answer, shown, cancelled, started, detached, inputTokens, outputTokens,
|
|
12091
|
-
turnOutputTokens, elapsedMs } = turn;
|
|
12368
|
+
turnOutputTokens, elapsedMs, stoppedCommand } = turn;
|
|
12092
12369
|
// Detached: the agent keeps running on the worker, so the question is neither answered
|
|
12093
12370
|
// nor stopped — leave it untagged, and say nothing contradicting "the job keeps running".
|
|
12094
12371
|
if (detached) continue;
|
|
@@ -12185,6 +12462,7 @@ async function main() {
|
|
|
12185
12462
|
// Stopped AFTER the model was called = too slow or stuck, never a change of mind: the
|
|
12186
12463
|
// request still stands (task #247, the user's rule). Before that, nothing had begun.
|
|
12187
12464
|
await markInterrupted(cwd, history, taskLine, started ? "stopped" : "cancelled");
|
|
12465
|
+
await afterStoppedTurn({ taskLine, submittedInput, turnStartMs, stoppedCommand, typed: line });
|
|
12188
12466
|
} else {
|
|
12189
12467
|
await markInterrupted(cwd, history, taskLine, "failed");
|
|
12190
12468
|
// The turn ended cleanly but empty — no exception to summarize, so say exactly that.
|
|
@@ -12197,6 +12475,8 @@ async function main() {
|
|
|
12197
12475
|
// turn that just threw auto-fire the next one.
|
|
12198
12476
|
if (_lineQueue.length > 0) _lineQueue.length = 0;
|
|
12199
12477
|
await rememberUnfinishedTask(cwd, taskLine); // …and "continue" can retry it (task #150)
|
|
12478
|
+
// #302 part 3: the connection went, not the turn — keep its job so "continue" follows it.
|
|
12479
|
+
lostTurn = err?.lostJobId ? { jobId: err.lostJobId, taskLine } : null;
|
|
12200
12480
|
printTurnError(err?.message, {
|
|
12201
12481
|
resumable: true,
|
|
12202
12482
|
failures: noteTaskFailure(taskLine, classifyTurnFailure(err?.message)),
|
package/image-paste.mjs
CHANGED
|
@@ -248,6 +248,16 @@ export function createImageStore() {
|
|
|
248
248
|
const present = new Set(String(line ?? "").match(TOKEN_RE) ?? []);
|
|
249
249
|
for (const t of [...store.keys()]) if (!present.has(t)) store.delete(t);
|
|
250
250
|
},
|
|
251
|
+
/** The live entries — snapshotted when a line is sent, so a stopped turn can give it back (#302). */
|
|
252
|
+
entries() {
|
|
253
|
+
return [...store];
|
|
254
|
+
},
|
|
255
|
+
/** Put one back exactly as it was (#302): same token, and later images number after it. */
|
|
256
|
+
restore(token, path) {
|
|
257
|
+
store.set(token, path);
|
|
258
|
+
const n = Number(/#(\d+)/.exec(String(token))?.[1]);
|
|
259
|
+
if (Number.isFinite(n) && n > seq) seq = n;
|
|
260
|
+
},
|
|
251
261
|
/** After a message is sent: the next one numbers its images from #1 again. */
|
|
252
262
|
clear() {
|
|
253
263
|
store.clear();
|