@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/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
- const setup = await runClientExpertSetup({ ask: ask0 });
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), { signal: AbortSignal.timeout(8000) })
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
- if (/^(no|n|skip|-)$/i.test(typed)) return null;
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
- for (const cand of candidates) {
5274
- try {
5275
- models = await load(cand);
5276
- n = cand;
5277
- if (candidates.length > 1) console.log(dim(` using ${os.displayUrl(cand.url)}`));
5278
- break;
5279
- } catch (err) {
5280
- lastErr = err;
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 'no' to skip — you can set it later with /model)"));
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; type 'no' to skip."));
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; type 'no' to skip a role."));
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
- const attached = Boolean(attachJobId);
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 (!attached && !(await ensureTokenForTurn())) return;
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(attached ? "attaching" : "sending");
9492
- const res = attached ? null : await fetch(`${apiBase}/api/worker/agent-ask`, {
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 = attached ? attachJobId : data?.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 (!attached && codingKeyMissingKnown === true) {
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(attached ? "attaching — reading its progress" : "queued — waiting for the worker");
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
- if (pollFailures >= MAX_POLL_FAILURES) {
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
- throw new Error(
10476
- `lost contact with the API after ${pollFailures} tries (${String(err.message).slice(0, 80)}). ` +
10477
- "The turn may still be running on the worker — try 'continue'."
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 setup = await runClientExpertSetup({ ask });
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 turn = await runAgentTurn(history);
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();