@tiens.nguyen/gonext-cli 1.0.350

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.
@@ -0,0 +1,2648 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `gonext` — terminal agent REPL (P6).
4
+ *
5
+ * Run it inside any project folder:
6
+ * $ cd ~/Projects/my-repo
7
+ * $ gonext
8
+ *
9
+ * It offers to register the CURRENT folder as an agent workspace (with a trust prompt
10
+ * that maps to --allow-run), authenticates with the worker key from ~/.gonext/worker.env
11
+ * (the same file `gonext-cli set` / the Wizard write), and loops on a `>> `
12
+ * prompt. Each question is ENQUEUED as a normal agent_chat job (models/budgets/RAG
13
+ * resolved from your user settings by POST /api/worker/agent-ask) and executed by the
14
+ * RUNNING gonext-cli daemon — so its terminal shows the full [gonext-agent]
15
+ * logs, exactly like a web question. The REPL polls the job and streams the persisted
16
+ * chunks live into the terminal.
17
+ *
18
+ * Conversation history is saved to ~/.gonext/sessions/<hash-of-folder-path>.json after
19
+ * every turn and reloaded on the next `gonext` in the SAME folder — so closing the
20
+ * terminal and coming back later still has context (e.g. "continue" after an
21
+ * interrupted investigation actually resumes it). /reset clears it.
22
+ *
23
+ * Requires the worker daemon to be running (it claims the job).
24
+ * Commands: /exit /model /reset /revert [runId] /help Ctrl-C stops following the current run.
25
+ */
26
+ import { readFile, mkdir, writeFile, unlink } from "node:fs/promises";
27
+ import { existsSync, statSync, readFileSync } from "node:fs";
28
+ import { spawn } from "node:child_process";
29
+ import { homedir } from "node:os";
30
+ import { dirname, join, resolve, basename } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+ import { createInterface } from "node:readline";
33
+ import { createHash } from "node:crypto";
34
+ import { createRequire } from "node:module";
35
+ import dotenv from "dotenv";
36
+
37
+ const DIR = dirname(fileURLToPath(import.meta.url));
38
+
39
+ // Running version, from the package's own package.json (task #105) — shown in the REPL
40
+ // banner so it's clear which build is running.
41
+ const REPL_VERSION = (() => {
42
+ try {
43
+ return createRequire(import.meta.url)("./package.json").version ?? "unknown";
44
+ } catch {
45
+ return "unknown";
46
+ }
47
+ })();
48
+ const ENV_FILE = join(homedir(), ".gonext", "worker.env");
49
+ const WS_FILE = join(homedir(), ".gonext", "workspaces.json");
50
+ const SESSIONS_DIR = join(homedir(), ".gonext", "sessions");
51
+
52
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
53
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
54
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
55
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
56
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
57
+ const white = (s) => `\x1b[37m${s}\x1b[0m`;
58
+ // ANSI 90 ("bright black") — a reliable neutral grey across terminal themes, unlike
59
+ // code 34 (blue) which many dark-theme palettes render as purple/indigo instead.
60
+ const codeColor = (s) => `\x1b[90m${s}\x1b[0m`;
61
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
62
+ // Hover highlight for the clickable thinking line (task #96): just shift the color a little
63
+ // toward white (no bold/underline/anchor) so it subtly signals "hovered" without looking like
64
+ // a link. 97 = bright white.
65
+ const hoverThought = (s) => `\x1b[97m${s}\x1b[0m`;
66
+
67
+ // The worker's "still thinking" heartbeat lines look like "Caffeinating… (90s)" — we
68
+ // SWALLOW them from the terminal (the local ticker below drives progress instead), and
69
+ // also drop the worker's ~~~ stream fences (they only matter to the web renderer).
70
+ const isHeartbeatLine = (line) => /…\s?\(\d+s\)\s*$/.test(line);
71
+ const isFenceLine = (line) => line.trim() === "~~~";
72
+ // The model's raw Python code blob is delimited by <code>/</code> tags. We don't print
73
+ // the tags themselves, but the code IN BETWEEN prints in a distinct grey (codeColor)
74
+ // instead of dim prose, so it visually separates from the "Thought:" reasoning.
75
+ // Tolerant matching, NOT exact-line: the raw token stream often mangles the tags —
76
+ // glued to content ("<codecreate_folder(...)"), missing the ">", duplicated, or the
77
+ // close tag stuck on the code's last line — and exact matching printed those
78
+ // fragments literally in mixed colors (user-reported).
79
+ const CODE_OPEN_RE = /^<code(?:\s*>)?/; // applied to line.trim()
80
+ const CODE_CLOSE_RE = /<\/code>?\s*$/;
81
+ const isBareCloseTag = (t) => /^<\/code>?$/.test(t);
82
+ // The step summary the worker emits right after a tool call finishes (e.g.
83
+ // "unzip_file(...) | → Unzipped 256 files…") duplicates what's already visible in the
84
+ // gonext-cli daemon's own terminal — swallow it here to keep the REPL terse.
85
+ const isToolStepSummary = (line) => / \| → /.test(line);
86
+ // The router's internal play-by-play ("Routing your request…", "→ Agent mode (needs
87
+ // tools)", "→ Chat reply", "Choosing a tool…", "Composing a reply…") is implementation
88
+ // detail, not something a user needs — also visible in the daemon's own terminal.
89
+ // Swallowed; see routingAnnounced. "Composing a reply…" specifically must be included so
90
+ // it doesn't print (undimmed, since plainReplyFlow is already true by then) right before
91
+ // the real streamed answer, where it would visually blend into the answer text.
92
+ const isRoutingLine = (line) =>
93
+ /^(Routing your request…|Choosing a tool…|Composing a reply…|→ Agent mode.*|→ Chat reply)$/.test(
94
+ line.trim()
95
+ );
96
+ // The edit tools (edit_lines/edit_file/create_file) emit a multi-line "edit card"
97
+ // as one step event — a small stable line protocol rendered here as a colored diff
98
+ // (bold header, red '-' / green '+' lines, dim line numbers). Shapes must stay in
99
+ // sync with _render_edit_card in gonext_agent_chat.py:
100
+ // ⏺ Update(XeroForwardService.java)
101
+ // └ Replaced lines 19-19 with 1 line
102
+ // 19 - ...old...
103
+ // 19 + ...new...
104
+ // … (+3 more)
105
+ const isEditCardHeader = (line) => /^⏺ (Update|Create)\(.+\)/.test(line);
106
+ const EDIT_DIFF_RE = /^(\s{4})(\d+) ([-+]) (.*)$/;
107
+ const isEditCardBody = (line) =>
108
+ /^\s*└ /.test(line) || EDIT_DIFF_RE.test(line) || /^\s{4}… /.test(line);
109
+
110
+ // Playful words for the local "thinking…" ticker, reused verbatim from the worker's
111
+ // thinking_words.txt sibling (falls back to a tiny built-in list if it's missing).
112
+ const THINKING_WORDS = (() => {
113
+ try {
114
+ return readFileSync(join(DIR, "thinking_words.txt"), "utf8")
115
+ .split("\n")
116
+ .map((l) => l.trim())
117
+ .filter((l) => l && !l.startsWith("#"));
118
+ } catch {
119
+ return ["Thinking", "Percolating", "Cogitating", "Noodling", "Ruminating"];
120
+ }
121
+ })();
122
+ const pickWord = () =>
123
+ THINKING_WORDS[Math.floor(Math.random() * THINKING_WORDS.length)] || "Thinking";
124
+ // In-progress ("blinking") spinner for the step being worked on. 30 styles that rotate
125
+ // ~every 8s so a long CPU-bound wait stays interesting — mirrors the web agent's set
126
+ // (keep in sync with the .spin-N @keyframes in web/src/App.css). Single-width glyphs so
127
+ // they never shift the text after them.
128
+ const SPINNERS = [
129
+ "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏", "│╱─╲", "▁▃▄▅▆▇▆▅▄▃", "←↖↑↗→↘↓↙", "▏▎▍▌▋▊▉▊▋▌▍▎",
130
+ "◴◷◶◵", "◐◓◑◒", "⣾⣽⣻⢿⡿⣟⣯⣷", "▖▘▝▗", "✶✸✹✺✹✷",
131
+ "⠁⠂⠄⡀⢀⠠⠐⠈", "dqpb", "◢◣◤◥", "✦✧", "◜◝◞◟",
132
+ "▌▀▐▄", "◰◳◲◱", "┤┘┴└├┌┬┐", "▘▝▗▖", "▸▹",
133
+ "⢄⢂⢁⡁⡈⡐⡠", "▙▛▜▟", "▂▃▄▅▆▇█▇▆▅▄▃", "⣿⣷⣯⣟⡿⢿⣻⣽", "⎺⎻⎼⎽",
134
+ "•◦", "░▒▓█▓▒", "◇◈◆", "·✢✳✽", "◠◡",
135
+ ].map((s) => [...s]);
136
+ // The playful word's color cycles ~every 2.5s (xterm-256 palette matching the web accents:
137
+ // green, cyan, blue, indigo, pink, amber).
138
+ const WAIT_COLORS_256 = [120, 87, 111, 147, 218, 221];
139
+ const color256 = (n, s) => `\x1b[38;5;${n}m${s}\x1b[0m`;
140
+ const orange = (s) => color256(208, s); // peak-token label (task #84)
141
+ // Compact token count: 950 → "950", 12300 → "12.3k", 1500000 → "1.5M" (task #83).
142
+ const fmtTokens = (n) => {
143
+ if (n < 1000) return String(n);
144
+ if (n < 1_000_000) return `${(n / 1000).toFixed(n < 100_000 ? 1 : 0)}k`;
145
+ return `${(n / 1_000_000).toFixed(1)}M`;
146
+ };
147
+ const BULLET = "●"; // a completed action
148
+
149
+ // ---------- flags ----------
150
+ const argv = process.argv.slice(2);
151
+ if (argv.includes("--help") || argv.includes("-h")) {
152
+ console.log(
153
+ "gonext — terminal agent REPL for the current folder\n\n" +
154
+ "Usage: gonext [-v|--verbose]\n\n" +
155
+ " -v, --verbose show the agent's diagnostic logs ([gonext-agent] …) and python stderr\n" +
156
+ " -h, --help this help\n\n" +
157
+ "Auth comes from ~/.gonext/worker.env; models/budgets/RAG come from your web Settings.\n" +
158
+ "Inside the REPL: /exit · /model · /reset · /revert [runId] · /help — Ctrl-C stops following a turn.\n" +
159
+ "Questions are enqueued like web questions and executed by the RUNNING\n" +
160
+ "gonext-cli daemon — its terminal shows the full [gonext-agent] logs.\n" +
161
+ "Conversation history is saved per-folder in ~/.gonext/sessions and resumed next time\n" +
162
+ "you run `gonext` in the same folder; /reset forgets it and starts fresh."
163
+ );
164
+ process.exit(0);
165
+ }
166
+ if (argv.includes("-v") || argv.includes("--verbose")) {
167
+ process.env.GONEXT_REPL_VERBOSE = "1";
168
+ }
169
+
170
+ // ---------- auth: worker key + API base from ~/.gonext/worker.env ----------
171
+ dotenv.config({ path: ENV_FILE });
172
+ dotenv.config();
173
+ let workerKey = String(process.env.GONEXT_WORKER_KEY ?? "").trim();
174
+ let apiBase = String(process.env.GONEXT_API_BASE ?? "").trim().replace(/\/+$/, "");
175
+ // Task #127 (BC1a): "configured" = BOTH key AND api-base present. When either is missing,
176
+ // first-run login instead of a dead end — but ONLY interactively; a piped/CI invocation
177
+ // still fails fast with guidance (so scripts don't hang on a browser prompt). An EXISTING
178
+ // user with a full worker.env skips all of this and behaves exactly as before (BC1).
179
+ if (!workerKey || !apiBase) {
180
+ if (process.stdin.isTTY && process.stdout.isTTY) {
181
+ try {
182
+ const { deviceLogin, DEFAULT_API_BASE } = await import("./device-login.mjs");
183
+ console.log(
184
+ dim("gonext: first-time setup — let's connect this machine to your account.")
185
+ );
186
+ const r = await deviceLogin({
187
+ apiBase: apiBase || DEFAULT_API_BASE,
188
+ log: (s) => console.log(s),
189
+ });
190
+ workerKey = r.workerKey;
191
+ apiBase = r.apiBase;
192
+ } catch (e) {
193
+ console.error(red(`gonext: login failed: ${e instanceof Error ? e.message : e}`));
194
+ if (e && e.code === "PAIRING_UNSUPPORTED") {
195
+ console.error(
196
+ "Configure manually: gonext-cli set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
197
+ );
198
+ }
199
+ process.exit(1);
200
+ }
201
+ } else {
202
+ console.error(
203
+ red("gonext: no worker key / API base found.") +
204
+ `\nRun ${cyan("gonext-cli login")} to sign in, or configure manually:` +
205
+ "\n gonext-cli set <workerKey> --api-base https://chat-api-v2.gomarsic.cc"
206
+ );
207
+ process.exit(1);
208
+ }
209
+ }
210
+
211
+ // Line intake: attach the listener IMMEDIATELY so lines that arrive while startup
212
+ // awaits (workspace prompt fetch, payload probe) are queued instead of lost — vital
213
+ // for piped/scripted input, harmless for interactive TTYs. Both ask() and the main
214
+ // loop consume from this queue (never rl.question, to avoid double-capture).
215
+ //
216
+ // terminal: true (on real TTYs) with readline OWNING the prompt via setPrompt/prompt().
217
+ // History of this setting: plain terminal:true with a MANUALLY-written ">> " once caused
218
+ // a stray "[" artifact — readline redrew the input line using its own (empty, never-set)
219
+ // prompt and cursor tracking that our un-announced writes had made stale. The interim
220
+ // fix (terminal:false) traded that for something worse: the OS's canonical line
221
+ // discipline echoes raw CSI bytes, so arrows/Home/End showed literal "^[[D" and mid-line
222
+ // editing didn't work at all (user-reported). Giving readline the prompt makes its
223
+ // redraw math correct — rl.prompt() resets the cursor state after every burst of our
224
+ // output — and raw mode means backspace/arrows/↑-history all behave like a normal shell.
225
+ // Single source of truth for the REPL's slash commands — drives Tab-completion, the
226
+ // unknown-command guard, and /help, so they can never drift.
227
+ const COMMANDS = [
228
+ { name: "/model", desc: "switch the coding model for this session" },
229
+ { name: "/test-auto", desc: "toggle auto-test (verify & fix until it passes) for this folder" },
230
+ { name: "/rag-local", desc: "toggle storing this folder's RAG knowledge base locally (vs cloud/S3)" },
231
+ { name: "/server", desc: "pick a deployment server (host/user) for this session" },
232
+ { name: "/revert", usage: "/revert [runId]", desc: "undo the agent's file edits (latest run)" },
233
+ { name: "/reset", aliases: ["/new"], desc: "forget this folder's saved conversation and start fresh" },
234
+ { name: "/output-token", desc: "show the agent code-model OUTPUT-token total for this workspace" },
235
+ { name: "/reset-output-token-global", desc: "reset the GLOBAL (you + coding server) output-token total to 0" },
236
+ { name: "/help", desc: "list these commands" },
237
+ { name: "/exit", aliases: ["/quit"], desc: "quit" },
238
+ ];
239
+ const COMMAND_NAMES = COMMANDS.flatMap((c) => [c.name, ...(c.aliases ?? [])]);
240
+ // Tab-completion: when the line starts with "/", complete against the command names.
241
+ function slashCompleter(line) {
242
+ if (!line.startsWith("/")) return [[], line];
243
+ const hits = COMMAND_NAMES.filter((n) => n.startsWith(line));
244
+ return [hits.length ? hits : COMMAND_NAMES, line];
245
+ }
246
+ // Commands whose name/alias matches the typed prefix (all when prefix is "" or "/").
247
+ function filteredCommands(prefix = "") {
248
+ const p = (prefix ?? "").trim();
249
+ const m = COMMANDS.filter(
250
+ (c) =>
251
+ !p ||
252
+ p === "/" ||
253
+ [c.name, ...(c.aliases ?? [])].some((n) => n.startsWith(p))
254
+ );
255
+ return m.length ? m : COMMANDS;
256
+ }
257
+ // Display rows for a filtered command set; row `sel` (if >=0) is highlighted (❯ + bold).
258
+ function hintRows(cmds, sel) {
259
+ const rows = [dim(" commands (↑/↓ select · Enter run · Tab complete):")];
260
+ cmds.forEach((c, i) => {
261
+ const label = c.usage ?? c.name;
262
+ const alias = c.aliases?.length ? dim(` (${c.aliases.join(", ")})`) : "";
263
+ const body = `${cyan(label)}${alias}${dim(" — " + c.desc)}`;
264
+ rows.push(i === sel ? `${green("❯")} ${bold(body)}` : ` ${body}`);
265
+ });
266
+ return rows;
267
+ }
268
+ // Permanent print (used by /help and a lone-"/" submit — scrolls with history).
269
+ function printCommands(prefix = "") {
270
+ for (const r of hintRows(filteredCommands(prefix), -1)) console.log(r);
271
+ console.log("");
272
+ }
273
+
274
+ const IS_TTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
275
+ const rl = createInterface({
276
+ input: process.stdin,
277
+ output: process.stdout,
278
+ terminal: IS_TTY,
279
+ completer: slashCompleter,
280
+ });
281
+ const PROMPT = cyan(">> ");
282
+ rl.setPrompt(PROMPT);
283
+ // Side effect of terminal:false above: readline no longer intercepts arrow keys for
284
+ // command-history recall, so pressing ↑/↓/→/← (or Home/End/Delete) sends the RAW escape
285
+ // bytes straight through as literal input — the OS's own canonical line discipline
286
+ // doesn't understand them either, so they get echoed and end up IN the submitted line
287
+ // once Enter is pressed (seen live: a task sent to the model started with a literal
288
+ // "\x1b[A" from an accidental up-arrow press). Strip CSI escape sequences (ESC '[' …
289
+ // final-byte) from every submitted line so stray arrow-key presses can't corrupt task
290
+ // text sent to the model.
291
+ const stripAnsiEscapes = (s) => String(s ?? "").replace(/\x1b\[[0-9;]*[A-Za-z~]/g, "");
292
+ // A fetch() failure (DNS, connection reset, timeout) throws rather than returning a
293
+ // response — recognize it so the poll loop can treat it as a transient blip, not a hard
294
+ // stop. Node's fetch throws a TypeError ("fetch failed") wrapping the socket error.
295
+ const isNetworkError = (e) =>
296
+ e instanceof TypeError ||
297
+ /fetch failed|network|ECONN|ETIMEDOUT|EAI_AGAIN|socket hang up|und_err|terminated/i.test(
298
+ String(e?.message ?? e)
299
+ );
300
+ const _lineQueue = [];
301
+ let _lineWaiter = null;
302
+ let _stdinClosed = false;
303
+ let slashHintRows = 0; // live slash-command hint rows drawn below the prompt (0 = none)
304
+ let slashSel = -1; // highlighted row in the current filtered list (-1 = none)
305
+ let slashNames = []; // command names in the current filtered list (for Enter → run)
306
+ let slashPrefix = ""; // the "/…" text the user typed (restored when ↑/↓ recall history)
307
+ rl.on("line", (l) => {
308
+ // A list picker owns the keyboard (its Enter resolves via the keypress handler) — never
309
+ // let that Enter submit a stray empty line into the command loop.
310
+ if (listPickerActive) {
311
+ rl.line = "";
312
+ rl.cursor = 0;
313
+ return;
314
+ }
315
+ // While a turn is running, IGNORE typed input entirely — no type-ahead, no queued
316
+ // next question. Only Ctrl+C (handled via SIGINT) does anything mid-turn. Clear the
317
+ // line buffer so nothing the user typed leaks into the next prompt.
318
+ if (following) {
319
+ rl.line = "";
320
+ rl.cursor = 0;
321
+ return;
322
+ }
323
+ // Enter left the cursor on the row where the live slash-hint started — erase it so the
324
+ // command's own output doesn't print over/under a stale hint block. If a menu item was
325
+ // highlighted (↑/↓), RUN that command instead of the raw typed line.
326
+ let submitted = l;
327
+ if (slashOverlayActive) {
328
+ // A highlighted menu item (↑/↓) RUNS that command instead of the raw typed line.
329
+ if (slashSel >= 0 && slashNames[slashSel]) submitted = slashNames[slashSel];
330
+ closeSlashOverlay(); // wipe the menu, re-enable readline, restore history
331
+ // readline's own newline echo was suppressed during the overlay — emit it so the
332
+ // command's output starts on a fresh line, not over the ">> …" input line (#109).
333
+ process.stdout.write("\r\n");
334
+ }
335
+ const clean = stripAnsiEscapes(submitted);
336
+ if (_lineWaiter) {
337
+ const w = _lineWaiter;
338
+ _lineWaiter = null;
339
+ w(clean);
340
+ } else {
341
+ _lineQueue.push(clean);
342
+ }
343
+ });
344
+ // Neutralize navigation keys (↑/↓ history recall, ←/→ cursor, Home/End, etc.) during a
345
+ // turn: echo is already muted, but readline still recalls history into the invisible
346
+ // buffer on ↑/↓ — reset the line state after every keypress so nothing accumulates or
347
+ // moves. Ctrl+C is unaffected (readline emits its SIGINT before this handler runs).
348
+ // Live command hint = an EPHEMERAL, SELECTABLE overlay drawn BELOW the prompt while the
349
+ // line starts with "/". It redraws as you type (narrowing), ↑/↓ move a highlight, Enter
350
+ // runs the highlighted command, and it DISAPPEARS when you clear the slash — no permanent
351
+ // scrollback copies. Relative cursor moves so it survives terminal scrolling.
352
+ // (slashSel / slashNames / slashPrefix are declared above, near rl.on("line").)
353
+ // While the slash-hint overlay is open, EMPTY readline's history so ↑/↓ don't recall a
354
+ // history entry and trigger readline's OWN line-refresh — that refresh fought our overlay
355
+ // and reprinted the whole ">> /" + hint block on every arrow press (bug #109). With no
356
+ // history to recall, readline's ↑/↓ are inert and our keypress handler owns the highlight.
357
+ // Restored the instant the overlay closes, so normal task-history recall still works.
358
+ let _slashSavedHistory = null;
359
+ function slashNeutralizeHistory() {
360
+ if (_slashSavedHistory === null && Array.isArray(rl.history)) {
361
+ _slashSavedHistory = rl.history;
362
+ rl.history = [];
363
+ rl.historyIndex = -1;
364
+ }
365
+ }
366
+ function slashRestoreHistory() {
367
+ if (_slashSavedHistory !== null) {
368
+ rl.history = _slashSavedHistory;
369
+ _slashSavedHistory = null;
370
+ rl.historyIndex = -1;
371
+ }
372
+ }
373
+ // While the overlay is active WE own all terminal drawing: readline's own echo +
374
+ // line-refresh are no-op'd (see slashOverlayActive in _writeToOutput/_refreshLine
375
+ // below), exactly like the /server + approval pickers. That's what makes the redraw
376
+ // stable — readline can't clearScreenDown over our menu or fight our cursor (bug #109,
377
+ // which the earlier relative-cursor overlay hit whenever readline refreshed or the
378
+ // menu was near the bottom of the screen and scrolled).
379
+ let slashOverlayActive = false;
380
+ // While true, readline's echo + line-refresh are muted so a typed secret (an API key) never
381
+ // reaches the terminal or scrollback (task #127 — see askSecret + the _writeToOutput guards).
382
+ let secretInput = false;
383
+
384
+ // Visible width of PROMPT (">> ") — used to put the caret back on the input line by
385
+ // COLUMN, since the string itself carries color escapes that don't occupy cells.
386
+ const PROMPT_COLS = 3;
387
+
388
+ // Truncate to `max` VISIBLE columns, keeping the ANSI color escapes (which occupy no
389
+ // cells) intact. Essential for the overlay: every row it draws must fit on ONE physical
390
+ // terminal row — see slashOverlaySeq for why a wrapped row corrupts the redraw.
391
+ function clipVisible(s, max) {
392
+ if (max <= 0) return "";
393
+ let out = "", seen = 0, sawEscape = false;
394
+ for (let i = 0; i < s.length; i++) {
395
+ if (s[i] === "\x1b") {
396
+ const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i));
397
+ if (m) { out += m[0]; i += m[0].length - 1; sawEscape = true; continue; }
398
+ }
399
+ if (seen >= max) return out + (sawEscape ? "\x1b[0m" : "");
400
+ out += s[i];
401
+ seen++;
402
+ }
403
+ return out;
404
+ }
405
+
406
+ // Build the exact bytes that draw the overlay: the input line (">> " + typed text) with
407
+ // the command menu below it, caret left on the input line.
408
+ //
409
+ // Bug #109 v3 — the ↑/↓ stacking. TWO things have to hold for an in-place redraw, and the
410
+ // old version got the first right and the second wrong:
411
+ //
412
+ // 1. NO ABSOLUTE ANCHOR. The old code re-anchored each redraw with DECSC/DECRC
413
+ // (ESC 7 / ESC 8), an absolute screen position, after pre-scrolling "reserved" rows
414
+ // into existence. There is one save slot per terminal and its meaning changes as soon
415
+ // as the screen scrolls, so it is fragile. Every move here is RELATIVE (ESC[nA) or
416
+ // column-absolute within the current row (ESC[nG), like drawList() (the /server
417
+ // picker), which has always redrawn correctly.
418
+ //
419
+ // 2. NO WRAPPED ROWS — this is what actually bit the user. `ESC[nA` moves up n PHYSICAL
420
+ // rows, but `n` here is the number of LOGICAL menu rows. On a narrow terminal a row
421
+ // like " /model — switch the coding model for this session" occupies TWO physical
422
+ // rows, so the walk back up lands short, the next redraw's "\r ESC[2K" clears the
423
+ // wrong line, and a SECOND ">> /" + menu block is painted below the first — exactly
424
+ // the reported "it prints the item on every ↑". Verified in a terminal model: at 40
425
+ // columns the uncorrected version produces three prompt rows. So every row is clipped
426
+ // to the viewport width, which makes logical rows and physical rows the same thing.
427
+ // (This is also why the /server picker never showed the bug: its rows are short.)
428
+ function slashOverlaySeq(line, cursor, menu, cols) {
429
+ const width = Math.max(20, cols || 80);
430
+ const n = menu.length;
431
+ let out = "\r\x1b[2K" + clipVisible(PROMPT + line, width - 1);
432
+ for (const r of menu) out += "\n\x1b[2K" + clipVisible(r, width - 1);
433
+ // Wipe anything left below (the filtered list can SHRINK as you type), then walk back
434
+ // up to the input line and park the caret at the typed-text offset.
435
+ out += "\x1b[J";
436
+ if (n > 0) out += `\x1b[${n}A`;
437
+ out += `\r\x1b[${Math.min(width, PROMPT_COLS + cursor + 1)}G`;
438
+ return out;
439
+ }
440
+
441
+ // Redraw the input line AND the command menu below it, fully in place.
442
+ function renderSlashOverlay(sel) {
443
+ const line = rl.line;
444
+ const cmds = filteredCommands(line);
445
+ slashNames = cmds.map((c) => c.name);
446
+ if (sel >= cmds.length) sel = cmds.length - 1;
447
+ slashSel = sel;
448
+ slashPrefix = line;
449
+ const menu = hintRows(cmds, sel);
450
+ if (!slashOverlayActive) {
451
+ slashOverlayActive = true;
452
+ slashNeutralizeHistory();
453
+ }
454
+ slashHintRows = menu.length;
455
+ process.stdout.write(
456
+ slashOverlaySeq(line, rl.cursor ?? line.length, menu, process.stdout.columns)
457
+ );
458
+ }
459
+
460
+ // Close the overlay: erase the menu below, keep the typed input line, hand drawing back
461
+ // to readline. Callers that changed the line to a non-slash value should refresh after.
462
+ // Relative moves only, for the same reason as above.
463
+ function closeSlashOverlay() {
464
+ if (!slashOverlayActive) {
465
+ slashRestoreHistory();
466
+ return;
467
+ }
468
+ process.stdout.write(
469
+ slashCloseSeq(rl.line, rl.cursor ?? rl.line.length, process.stdout.columns)
470
+ );
471
+ slashOverlayActive = false;
472
+ slashHintRows = 0;
473
+ slashSel = -1;
474
+ slashNames = [];
475
+ slashRestoreHistory();
476
+ }
477
+
478
+ // Bytes that tear the menu down: repaint the input line, erase everything below it, and
479
+ // leave the caret back on the input line at the typed-text offset.
480
+ function slashCloseSeq(line, cursor, cols) {
481
+ const width = Math.max(20, cols || 80);
482
+ return (
483
+ "\r\x1b[2K" + clipVisible(PROMPT + line, width - 1) + // clean, non-wrapping input row
484
+ "\n\x1b[J" + // step below, erase the menu (and anything under it)
485
+ "\x1b[1A" + // back up to the input row (relative)
486
+ `\r\x1b[${Math.min(width, PROMPT_COLS + cursor + 1)}G`
487
+ );
488
+ }
489
+ // A window resize reflows every row, so the overlay's row arithmetic (and the width it
490
+ // clipped to) is stale the moment it happens: rows that fit before may now wrap, which is
491
+ // precisely what made the menu stack in the first place (#109 v3). Redraw at the new width
492
+ // — the render ends with ESC[J, so a block that used to be taller leaves no orphans behind.
493
+ if (process.stdout.isTTY) {
494
+ process.stdout.on("resize", () => {
495
+ if (!slashOverlayActive) return;
496
+ try {
497
+ renderSlashOverlay(slashSel);
498
+ } catch {
499
+ closeSlashOverlay(); // never let a resize wedge the prompt
500
+ }
501
+ });
502
+ }
503
+ if (process.stdin.isTTY) {
504
+ process.stdin.on("keypress", (_ch, key) => {
505
+ // An arrow-key list picker (/server) is up → ↑/↓ move the highlight, Enter chooses.
506
+ if (listPickerActive) {
507
+ onListKey(key);
508
+ rl.line = "";
509
+ rl.cursor = 0;
510
+ rl.historyIndex = -1;
511
+ return;
512
+ }
513
+ // A command-approval picker is up → arrow keys move the highlight, Enter chooses.
514
+ if (approvalActive) {
515
+ onApprovalKey(key);
516
+ rl.line = "";
517
+ rl.cursor = 0;
518
+ rl.historyIndex = -1;
519
+ return;
520
+ }
521
+ if (following) {
522
+ rl.line = "";
523
+ rl.cursor = 0;
524
+ rl.historyIndex = -1;
525
+ return;
526
+ }
527
+ // Enter is handled by rl.on("line") — don't redraw the hint on the way out.
528
+ if (key && (key.name === "return" || key.name === "enter")) return;
529
+ // ↑/↓ while the menu is up = move the highlight IN PLACE. readline drawing is
530
+ // suppressed (slashOverlayActive) and history neutralized, so its own ↑/↓ do
531
+ // nothing — we just move the selection and re-render the overlay (bug #109).
532
+ if (slashOverlayActive && key && (key.name === "up" || key.name === "down")) {
533
+ rl.historyIndex = -1;
534
+ const n = slashNames.length;
535
+ if (n > 0) {
536
+ slashSel =
537
+ key.name === "down" ? (slashSel + 1 + n) % n : (slashSel - 1 + n) % n;
538
+ }
539
+ renderSlashOverlay(slashSel);
540
+ return;
541
+ }
542
+ if (rl.line.startsWith("/")) {
543
+ // Typing narrows the list and resets the selection to none.
544
+ renderSlashOverlay(-1);
545
+ } else if (slashOverlayActive) {
546
+ // The line no longer starts with "/" (e.g. backspaced the slash away): tear down
547
+ // the overlay and let readline redraw the now-normal line.
548
+ closeSlashOverlay();
549
+ rl._refreshLine();
550
+ }
551
+ });
552
+ }
553
+ rl.on("close", () => {
554
+ _stdinClosed = true;
555
+ if (_lineWaiter) {
556
+ const w = _lineWaiter;
557
+ _lineWaiter = null;
558
+ w(null);
559
+ }
560
+ });
561
+ // Mouse hover+click on the thinking line (task #96): while a turn runs we turn ON xterm
562
+ // ANY-MOTION tracking so we can both HIGHLIGHT the line as the mouse hovers it AND expand it
563
+ // on click (see runAgentTurn). Trade-off the user accepted: with tracking on, the terminal's
564
+ // own drag-to-select/copy is captured by us — the user holds Option/Shift to select text
565
+ // meanwhile. ?1003 = report every button press/release AND all mouse MOTION (needed for hover
566
+ // with no button held); ?1006 = SGR extended coords (no 223-col cap). We disable ?1000/?1002
567
+ // too on teardown in case a terminal had them latched.
568
+ const MOUSE_ON = "\x1b[?1003h\x1b[?1006h";
569
+ const MOUSE_OFF = "\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?1006l";
570
+ let _mouseEnabled = false;
571
+ const enableMouse = () => {
572
+ if (_mouseEnabled || !process.stdin.isTTY) return;
573
+ process.stdout.write(MOUSE_ON);
574
+ _mouseEnabled = true;
575
+ };
576
+ const disableMouse = () => {
577
+ if (!_mouseEnabled) return;
578
+ process.stdout.write(MOUSE_OFF);
579
+ _mouseEnabled = false;
580
+ };
581
+ // Safety net: never leave the user's terminal in mouse-reporting mode (would break their
582
+ // shell's selection) if we exit ungracefully.
583
+ process.on("exit", disableMouse);
584
+ // SGR mouse report: ESC [ < btn ; col ; row (M=press, m=release). btn bit 32 = MOTION
585
+ // (hover/drag), bit 64 = wheel; low 2 bits = which button (0=left). A left CLICK is a press
586
+ // (M) with motion+wheel bits clear and low bits 0; a HOVER is any event with the motion bit.
587
+ const MOUSE_RE = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
588
+ // Cursor Position Report reply to our `\x1b[6n` probe: ESC [ row ; col R. We probe right after
589
+ // clearing the status (cursor is then at the status block's TOP row) to learn the thought
590
+ // line's ABSOLUTE viewport row — so hover/click hit-testing is exact even when the block isn't
591
+ // at the bottom of the screen (task #96 fix). No `<`, so it never collides with MOUSE_RE.
592
+ const DSR_RE = /\x1b\[(\d+);(\d+)R/g;
593
+ const nextLine = () =>
594
+ _lineQueue.length > 0
595
+ ? Promise.resolve(_lineQueue.shift())
596
+ : _stdinClosed
597
+ ? Promise.resolve(null)
598
+ : new Promise((res) => (_lineWaiter = res));
599
+ const ask = async (q) => {
600
+ // Route the question through readline's own prompt so its redraw logic stays
601
+ // consistent (a raw stdout write here would re-create the stale-cursor artifact
602
+ // that terminal:true mode is designed to avoid). Restored to ">> " afterwards.
603
+ rl.setPrompt(q);
604
+ rl.prompt();
605
+ const a = await nextLine();
606
+ rl.setPrompt(PROMPT);
607
+ return (a ?? "").trim();
608
+ };
609
+ // Yes/no prompt with an explicit default. Empty input (just Enter) → the default;
610
+ // otherwise the FIRST non-space character decides (y*→yes, n*→no), and anything else
611
+ // re-uses the default. This replaces a fragile `(await ask()) || "y"` idiom where any
612
+ // leftover character (a stray escape byte, a stray space) silently defeated the
613
+ // default — the reported "[Y/n] + Enter registered as No" bug.
614
+ // Masked prompt for a secret (an API key). Mutes readline's echo/refresh (secretInput)
615
+ // so the typed value never hits the terminal or scrollback (task #127). Returns "" on a
616
+ // bare Enter (skip).
617
+ const askSecret = async (q) => {
618
+ process.stdout.write(q);
619
+ secretInput = true;
620
+ try {
621
+ const a = await nextLine();
622
+ return (a ?? "").trim();
623
+ } finally {
624
+ secretInput = false;
625
+ process.stdout.write("\n"); // the muted Enter left the cursor mid-line
626
+ }
627
+ };
628
+ const askYesNo = async (question, defaultYes) => {
629
+ const suffix = defaultYes ? " [Y/n] " : " [y/N] ";
630
+ const raw = (await ask(question + suffix)).trim().toLowerCase();
631
+ if (!raw) return defaultYes; // just Enter → the default
632
+ if (raw[0] === "y") return true;
633
+ if (raw[0] === "n") return false;
634
+ return defaultYes; // unrecognized → default (never silently flip it)
635
+ };
636
+
637
+ // ---------- workspace registration for the current folder ----------
638
+ async function readWorkspaces() {
639
+ try {
640
+ const raw = JSON.parse(await readFile(WS_FILE, "utf8"));
641
+ return Array.isArray(raw?.workspaces) ? raw.workspaces : [];
642
+ } catch {
643
+ return [];
644
+ }
645
+ }
646
+
647
+ async function ensureWorkspace() {
648
+ const cwd = resolve(process.cwd());
649
+ const list = await readWorkspaces();
650
+ const hit = list.find((w) => cwd === w.path || cwd.startsWith(w.path + "/"));
651
+ if (hit) {
652
+ // Registered before cloud sync existed → ask once and persist. Otherwise honor the
653
+ // stored choice. (undefined = old record that never opted in → ask.)
654
+ if (hit.allowSync === undefined) {
655
+ hit.allowSync = await askYesNo(
656
+ "Allow syncing this workspace's chat history to the cloud?", true
657
+ );
658
+ await writeFile(WS_FILE, JSON.stringify({ workspaces: list }, null, 2) + "\n");
659
+ }
660
+ wsSync = { enabled: hit.allowSync !== false, name: hit.name, path: hit.path };
661
+ console.log(
662
+ dim(
663
+ `workspace: ${hit.name} → ${hit.path} (run ${hit.allowRun ? "enabled" : "disabled"}` +
664
+ `${hit.allowSync !== false ? ", cloud sync on" : ""})`
665
+ )
666
+ );
667
+ return;
668
+ }
669
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) return;
670
+ console.log(`This folder is not a registered workspace:\n ${cwd}`);
671
+ const register = await askYesNo(
672
+ "Register it so the agent can read/edit code here?", true /* default Yes */
673
+ );
674
+ if (!register) {
675
+ console.log(dim("Skipped — the agent will not be able to touch files here."));
676
+ return;
677
+ }
678
+ const allowRun = await askYesNo(
679
+ "Do you trust this folder — allow the agent to RUN build/test commands\n" +
680
+ "(npm test, mvn, pytest…)?",
681
+ true /* default Yes — you opened THIS folder with gonext deliberately, and the
682
+ action-first workflows (start/test/build the project) need it; commands are
683
+ still allowlisted (npm/mvn/pytest/…) and run with a scrubbed env. Type n to
684
+ keep a workspace read/edit-only. */
685
+ );
686
+ const allowSync = await askYesNo(
687
+ "Allow syncing this workspace's chat history to the cloud?\n" +
688
+ "(your conversation is saved to your account so you can see it on the web app)",
689
+ true /* default Yes — RAG is NOT synced (it stays on your S3); only chat history. */
690
+ );
691
+ const next = list.filter((w) => w.path !== cwd);
692
+ next.push({
693
+ name: basename(cwd),
694
+ path: cwd,
695
+ allowRun,
696
+ allowSync,
697
+ addedAt: new Date().toISOString(),
698
+ });
699
+ await mkdir(dirname(WS_FILE), { recursive: true });
700
+ await writeFile(WS_FILE, JSON.stringify({ workspaces: next }, null, 2) + "\n");
701
+ wsSync = { enabled: allowSync, name: basename(cwd), path: cwd };
702
+ console.log(
703
+ green(`✓ workspace registered: ${basename(cwd)}`) +
704
+ (allowRun ? green(" (run-commands enabled)") : dim(" (run-commands disabled)")) +
705
+ (allowSync ? green(" · cloud sync on") : dim(" · cloud sync off"))
706
+ );
707
+ }
708
+
709
+ // ---------- conversation persistence, keyed by folder ----------
710
+ // Without this, closing the terminal (or restarting `gonext`) loses ALL context — the
711
+ // next session starts from a blank slate even though the USER remembers the earlier
712
+ // investigation. "continue" (and general follow-ups) only make sense across restarts if
713
+ // there's something real on disk to continue FROM. Same hash-keying scheme the worker's
714
+ // python side already uses for per-URL RAG work dirs (sha256, 32 hex chars), applied
715
+ // here to the workspace path instead so returning to the SAME folder reloads the SAME
716
+ // conversation.
717
+ function sessionFilePath(cwd) {
718
+ const hash = createHash("sha256").update(cwd).digest("hex").slice(0, 32);
719
+ return join(SESSIONS_DIR, `${hash}.json`);
720
+ }
721
+
722
+ // Cap what's kept on disk — the worker already truncates what it actually SENDS to the
723
+ // model by a char budget (see "history: N prior turn(s), M chars (budget 8000)" in its
724
+ // logs), so persisting more than that budget could ever use is just unbounded growth
725
+ // for a long-lived folder with no benefit.
726
+ const MAX_PERSISTED_MESSAGES = 40;
727
+
728
+ // Cloud sync (task #47), opt-in per workspace. Set by ensureWorkspace() for the current
729
+ // folder; when enabled, saveSession/clearSession also mirror the chat history to Mongo
730
+ // via the worker-authed /api/worker/workspace-sync. RAG is NOT synced (stays on S3).
731
+ let wsSync = { enabled: false, name: "", path: "" };
732
+ const workspaceKeyFor = (cwd) =>
733
+ createHash("sha256").update(cwd).digest("hex").slice(0, 32);
734
+
735
+ // Task #104: agent CODE-model OUTPUT-token counters. The API keys the workspace total on
736
+ // workspaceKey and the GLOBAL total on (userId + coding-model URL). Task #125: the REPL
737
+ // now supplies the URL of the backend in EFFECT this session (activeCodingUrl) so the
738
+ // global total follows a /model backend switch; empty → the API falls back to the
739
+ // account-default URL from settings (older behaviour). All best-effort (never block a turn).
740
+ const activeCodingUrl = () => sessionCodingUrl || defaultCodingUrl;
741
+
742
+ // Task #126: the footer's global "↓" for the CURRENT backend. The saved-full-responses sum
743
+ // (savedResponsesTotal) is authoritative per-URL — and correct even for turns before #125,
744
+ // because each saved row carries its own url — so PREFER it when it has data. Fall back to
745
+ // the #125 incremental counter (globalTotal) when Save-Full-Response is off (no saved rows,
746
+ // so savedResponsesTotal is 0), which keeps a correct non-zero number instead of a
747
+ // surprising 0. Returns null when the response had neither, so callers keep the old value.
748
+ function pickGlobalTotal(t) {
749
+ if (!t) return null;
750
+ const saved = Number.isFinite(t.savedResponsesTotal) ? t.savedResponsesTotal : 0;
751
+ if (saved > 0) return saved;
752
+ return Number.isFinite(t.globalTotal) ? t.globalTotal : null;
753
+ }
754
+
755
+ async function fetchOutputTokenTotals(cwd) {
756
+ try {
757
+ const qs = new URLSearchParams({ workspaceKey: workspaceKeyFor(cwd) });
758
+ if (activeCodingUrl()) qs.set("codingUrl", activeCodingUrl());
759
+ const r = await fetch(`${apiBase}/api/worker/output-tokens?${qs.toString()}`, {
760
+ headers: { "X-Worker-Key": workerKey },
761
+ });
762
+ if (!r.ok) return null;
763
+ return await r.json();
764
+ } catch {
765
+ return null;
766
+ }
767
+ }
768
+
769
+ async function addOutputTokens(cwd, delta) {
770
+ if (!(delta > 0)) return null;
771
+ try {
772
+ const r = await fetch(`${apiBase}/api/worker/output-tokens`, {
773
+ method: "POST",
774
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
775
+ body: JSON.stringify({
776
+ workspaceKey: workspaceKeyFor(cwd),
777
+ delta,
778
+ codingUrl: activeCodingUrl(),
779
+ }),
780
+ });
781
+ if (!r.ok) return null;
782
+ return await r.json();
783
+ } catch {
784
+ return null;
785
+ }
786
+ }
787
+
788
+ // Save the agent coding-model API key (task #127): the terminal prompts for the Kimi K3 key
789
+ // when the default cloud coder has none, and POSTs it here (worker-key authed). Returns true
790
+ // on success. Best-effort — a failure just leaves the reminder for next startup.
791
+ async function saveCodingKey(apiKey) {
792
+ try {
793
+ const r = await fetch(`${apiBase}/api/worker/coding-key`, {
794
+ method: "POST",
795
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
796
+ body: JSON.stringify({ apiKey }),
797
+ });
798
+ return r.ok;
799
+ } catch {
800
+ return false;
801
+ }
802
+ }
803
+
804
+ async function resetGlobalOutputTokens() {
805
+ try {
806
+ const r = await fetch(`${apiBase}/api/worker/output-tokens/reset-global`, {
807
+ method: "POST",
808
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
809
+ body: JSON.stringify({ codingUrl: activeCodingUrl() }),
810
+ });
811
+ return r.ok;
812
+ } catch {
813
+ return false;
814
+ }
815
+ }
816
+
817
+ async function pushWorkspaceSync(cwd, history, { del = false } = {}) {
818
+ if (!wsSync.enabled) return;
819
+ const body = del
820
+ ? { workspaceKey: workspaceKeyFor(cwd), delete: true }
821
+ : {
822
+ workspaceKey: workspaceKeyFor(cwd),
823
+ name: wsSync.name || basename(cwd),
824
+ path: cwd,
825
+ history: history.slice(-MAX_PERSISTED_MESSAGES),
826
+ };
827
+ try {
828
+ await fetch(`${apiBase}/api/worker/workspace-sync`, {
829
+ method: "POST",
830
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
831
+ body: JSON.stringify(body),
832
+ });
833
+ } catch {
834
+ // Best-effort — a cloud-sync blip must never block the REPL (local save already done).
835
+ }
836
+ }
837
+
838
+ async function loadSession(cwd) {
839
+ try {
840
+ const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
841
+ return Array.isArray(raw?.history) ? raw.history : [];
842
+ } catch {
843
+ return [];
844
+ }
845
+ }
846
+
847
+ // The /test-auto choice is remembered per folder (stored alongside history in the same
848
+ // session file). Read it once at startup so the banner and agent-ask reflect it.
849
+ async function loadSessionTestAuto(cwd) {
850
+ try {
851
+ const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
852
+ return raw?.testAuto === true;
853
+ } catch {
854
+ return false;
855
+ }
856
+ }
857
+
858
+ // The /server deploy-target pick is also remembered per folder, so a fresh terminal in
859
+ // the same project still knows where to deploy (task #69 — it was session-only and got
860
+ // lost on restart). Returns {name,host,user} or null.
861
+ async function loadSessionServer(cwd) {
862
+ try {
863
+ const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
864
+ const s = raw?.deployServer;
865
+ return s && typeof s.host === "string" && typeof s.user === "string" ? s : null;
866
+ } catch {
867
+ return null;
868
+ }
869
+ }
870
+
871
+ // Peak single-request agent-code-model token count remembered per folder (task #84).
872
+ async function loadSessionMaxCodeTokens(cwd) {
873
+ try {
874
+ const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
875
+ return Number.isFinite(raw?.maxCodeTokens) ? raw.maxCodeTokens : 0;
876
+ } catch {
877
+ return 0;
878
+ }
879
+ }
880
+
881
+ // RAG storage backend remembered per folder (task #97): "local" | "cloud" | null (never
882
+ // chosen → we ask once at startup). Local keeps the index on disk under ~/.gonext; cloud
883
+ // uses the user's S3 bucket (the original behavior).
884
+ async function loadSessionRagMode(cwd) {
885
+ try {
886
+ const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
887
+ return raw?.ragMode === "local" || raw?.ragMode === "cloud" ? raw.ragMode : null;
888
+ } catch {
889
+ return null;
890
+ }
891
+ }
892
+
893
+ // Persist just the RAG-mode choice without touching history (used at startup, before the
894
+ // full session is loaded). Read-merge-write so nothing else in the file is lost.
895
+ async function saveSessionRagMode(cwd, mode) {
896
+ try {
897
+ let raw = {};
898
+ try {
899
+ raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8")) || {};
900
+ } catch {
901
+ raw = {};
902
+ }
903
+ raw.ragMode = mode;
904
+ raw.updatedAt = new Date().toISOString();
905
+ await mkdir(SESSIONS_DIR, { recursive: true });
906
+ await writeFile(sessionFilePath(cwd), JSON.stringify(raw, null, 2) + "\n");
907
+ } catch (err) {
908
+ console.error(dim(`(rag-mode save failed: ${err.message})`));
909
+ }
910
+ }
911
+
912
+ async function saveSession(cwd, history) {
913
+ try {
914
+ await mkdir(SESSIONS_DIR, { recursive: true });
915
+ const trimmed = history.slice(-MAX_PERSISTED_MESSAGES);
916
+ await writeFile(
917
+ sessionFilePath(cwd),
918
+ JSON.stringify(
919
+ {
920
+ cwd,
921
+ updatedAt: new Date().toISOString(),
922
+ testAuto: sessionTestAuto,
923
+ deployServer: selectedServer, // remember the /server pick per folder (task #69)
924
+ maxCodeTokens: sessionMaxCodeTokens, // peak per-request tokens (task #84)
925
+ ragMode: sessionRagMode, // local vs cloud RAG for this folder (task #97)
926
+ history: trimmed,
927
+ },
928
+ null,
929
+ 2
930
+ ) + "\n"
931
+ );
932
+ } catch (err) {
933
+ // Non-fatal — losing session persistence should never crash or block the REPL.
934
+ console.error(dim(`(session save failed: ${err.message})`));
935
+ }
936
+ // Mirror to the cloud AFTER the local write (fire-and-forget; opt-in via wsSync).
937
+ void pushWorkspaceSync(cwd, history);
938
+ }
939
+
940
+ async function clearSession(cwd) {
941
+ try {
942
+ await unlink(sessionFilePath(cwd));
943
+ } catch {
944
+ // Nothing on disk to clear — fine.
945
+ }
946
+ void pushWorkspaceSync(cwd, [], { del: true }); // /reset removes the cloud copy too
947
+ }
948
+
949
+ // ---------- fetch the ready-to-run agent payload from user settings ----------
950
+ // /model command: fetch the allowed coding models (default + web-curated whitelist),
951
+ // show a numbered picker, and set sessionCodingModel to the choice for this session.
952
+ async function chooseModel() {
953
+ let probe;
954
+ try {
955
+ probe = await fetchAgentPayload([{ role: "user", content: "ping" }]);
956
+ } catch (err) {
957
+ console.log(red(` couldn't load models: ${err.message}\n`));
958
+ return;
959
+ }
960
+ const allowed = Array.isArray(probe?.codingAllowed) ? probe.codingAllowed : [];
961
+ const def = (probe?.codingDefault ?? "").trim();
962
+ // Task #123: a choice is a (backend, model) PAIR — several backends (Ollama, an
963
+ // OpenAI-compatible endpoint, local MLX) can be enabled at once, and picking one moves
964
+ // the URL, the kind and the API key with it. `codingChoices` carries the backend of each
965
+ // entry so the list can say which is which; `codingAllowed` is the same ids, and stays
966
+ // the fallback for an API that predates this.
967
+ const choices = Array.isArray(probe?.codingChoices) && probe.codingChoices.length
968
+ ? probe.codingChoices
969
+ : allowed.map((id) => ({ id, model: id, kind: "" }));
970
+ if (choices.length === 0) {
971
+ console.log(dim(" No coding model configured. Set one in the web app → Settings → Agent.\n"));
972
+ return;
973
+ }
974
+ if (choices.length === 1) {
975
+ console.log(
976
+ dim(` Only one coding model is available: ${choices[0].model}.\n`) +
977
+ dim(" Add more in the web app → Settings → Agent → Coding backends.\n")
978
+ );
979
+ return;
980
+ }
981
+ const active = sessionCodingModel || def;
982
+ // Arrow-key picker (↑/↓ + Enter) — pre-highlight the active model, tag the default, and
983
+ // name the backend when more than one is in play (the same model name can appear twice).
984
+ const KIND_LABEL = { ollama: "Ollama", openai: "OpenAI-compatible", local: "local MLX", "": "auto" };
985
+ const showKind = new Set(choices.map((c) => c.kind ?? "")).size > 1;
986
+ const labels = choices.map((c) => {
987
+ const kind = showKind ? ` ${dim(`· ${KIND_LABEL[c.kind ?? ""] ?? c.kind}`)}` : "";
988
+ return `${c.model}${kind}${c.id === def ? " (default)" : ""}`;
989
+ });
990
+ const activeIdx = choices.findIndex((c) => c.id === active);
991
+ console.log(dim(" Choose the coding model (↑/↓ then Enter):"));
992
+ const idx = await pickFromList(labels, activeIdx >= 0 ? activeIdx : 0);
993
+ if (idx < 0) {
994
+ console.log(dim(" (cancelled)\n"));
995
+ return;
996
+ }
997
+ const chosen = choices[idx];
998
+ // Empty override means "use the account default" — so don't send an override when the
999
+ // user picks the default (keeps the payload clean and lets a later default change win).
1000
+ sessionCodingModel = chosen.id === def ? "" : chosen.id;
1001
+ // Remember the friendly name for the token footer, which should never show a raw
1002
+ // "<kind>::<model>" wire id.
1003
+ sessionCodingLabel = chosen.id === def ? "" : chosen.model;
1004
+ // Task #125: remember the chosen backend's coding URL so the GLOBAL output-token total is
1005
+ // keyed to it. Default pick → "" so activeCodingUrl() falls back to defaultCodingUrl.
1006
+ sessionCodingUrl = chosen.id === def ? "" : (chosen.url ?? "");
1007
+ const where = showKind ? ` (${KIND_LABEL[chosen.kind ?? ""] ?? chosen.kind})` : "";
1008
+ console.log(
1009
+ green(` ✓ coding model → ${chosen.model}${where}${chosen.id === def ? " (default)" : ""}\n`)
1010
+ );
1011
+ // Task #125: the GLOBAL lifetime total is per-backend, so re-fetch it for the newly
1012
+ // selected backend NOW — otherwise the footer would keep showing the previous backend's
1013
+ // total until the next turn. (The live per-turn tail, latestOutputTokens, is local to
1014
+ // runAgentTurn and is already 0 here between turns — /model can't run mid-turn — so there
1015
+ // is nothing to zero, and it isn't in scope from here.)
1016
+ const t = await fetchOutputTokenTotals(resolve(process.cwd()));
1017
+ { const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
1018
+ }
1019
+
1020
+ // Probe whether SSH KEY auth already works for a server (so we can tell the user to run
1021
+ // ssh-copy-id if not). BatchMode=yes = never prompt for a password → exit!=0 if no key.
1022
+ async function checkServerKeyAuth(s) {
1023
+ try {
1024
+ const ok = await new Promise((resolve) => {
1025
+ const p = spawn(
1026
+ "ssh",
1027
+ [
1028
+ "-o", "BatchMode=yes",
1029
+ "-o", "ConnectTimeout=6",
1030
+ "-o", "StrictHostKeyChecking=accept-new",
1031
+ `${s.user}@${s.host}`,
1032
+ "true",
1033
+ ],
1034
+ { stdio: "ignore" }
1035
+ );
1036
+ p.on("exit", (code) => resolve(code === 0));
1037
+ p.on("error", () => resolve(false));
1038
+ });
1039
+ if (ok) console.log(dim(" ✓ key auth works — deploys will connect without a password."));
1040
+ else
1041
+ console.log(
1042
+ yellow(" key auth not set up yet.") +
1043
+ dim(` Run once in your terminal: `) +
1044
+ `ssh-copy-id ${s.user}@${s.host}`
1045
+ );
1046
+ } catch {
1047
+ /* best-effort check — never block the picker */
1048
+ }
1049
+ }
1050
+
1051
+ // /server — pick a deployment target (from the web app's Servers registry) for this
1052
+ // session. Host/user only, never a secret; the agent deploys here with KEY auth (#69).
1053
+ async function chooseServer() {
1054
+ let servers;
1055
+ try {
1056
+ const res = await fetch(`${apiBase}/api/worker/deploy-servers`, {
1057
+ headers: { "X-Worker-Key": workerKey },
1058
+ });
1059
+ const data = await res.json().catch(() => ({}));
1060
+ if (!res.ok) {
1061
+ console.log(red(` couldn't load servers: ${data?.error || `HTTP ${res.status}`}\n`));
1062
+ return;
1063
+ }
1064
+ servers = Array.isArray(data?.servers) ? data.servers : [];
1065
+ } catch (err) {
1066
+ console.log(red(` couldn't load servers: ${err.message}\n`));
1067
+ return;
1068
+ }
1069
+ if (servers.length === 0) {
1070
+ console.log(dim(" No deployment servers yet — add them in the web app → Servers.\n"));
1071
+ return;
1072
+ }
1073
+ // Arrow-key picker: ↑/↓ to move, Enter to choose (no number typing). The list ends with
1074
+ // a "none" row to clear the selection. Pre-highlight the current pick if there is one.
1075
+ const labels = servers.map((s) => `${s.name} (${s.user}@${s.host})`);
1076
+ labels.push("none (clear selection)");
1077
+ const activeIdx = servers.findIndex((s) => s.host === selectedServer?.host);
1078
+ console.log(dim(" Choose a deployment server (↑/↓ then Enter):"));
1079
+ const idx = await pickFromList(labels, activeIdx >= 0 ? activeIdx : 0);
1080
+ if (idx < 0) {
1081
+ console.log(dim(" (cancelled)\n"));
1082
+ return;
1083
+ }
1084
+ if (idx === servers.length) {
1085
+ // the trailing "none" row
1086
+ selectedServer = null;
1087
+ console.log(dim(" ✓ deployment server cleared.\n"));
1088
+ return;
1089
+ }
1090
+ const s = servers[idx];
1091
+ selectedServer = { name: s.name, host: s.host, user: s.user };
1092
+ console.log(green(` ✓ deploy target → ${s.name} (${s.user}@${s.host})`));
1093
+ await checkServerKeyAuth(selectedServer);
1094
+ console.log("");
1095
+ }
1096
+
1097
+ async function fetchAgentPayload(messages) {
1098
+ const res = await fetch(`${apiBase}/api/worker/agent-payload`, {
1099
+ method: "POST",
1100
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
1101
+ body: JSON.stringify({ messages }),
1102
+ });
1103
+ const text = await res.text();
1104
+ let data;
1105
+ try {
1106
+ data = JSON.parse(text);
1107
+ } catch {
1108
+ data = {};
1109
+ }
1110
+ if (!res.ok) {
1111
+ if (res.status === 404) {
1112
+ throw new Error(
1113
+ "the API does not have POST /api/worker/agent-payload yet — deploy the latest API (sam build && sam deploy)."
1114
+ );
1115
+ }
1116
+ // Task #127 (BC1b): a present-but-INVALID worker key (rotated/revoked while worker.env
1117
+ // kept the stale one) shows up as 401/403. Point the user at re-login rather than a
1118
+ // generic error — we don't proactively re-validate the key, so the first rejection is
1119
+ // where this surfaces.
1120
+ if (res.status === 401 || res.status === 403) {
1121
+ throw new Error(
1122
+ "your worker key was rejected (it may have been rotated or revoked). " +
1123
+ "Run `gonext-cli login` to sign in again, or update it with " +
1124
+ "`gonext-cli set <workerKey> --api-base <url>`."
1125
+ );
1126
+ }
1127
+ throw new Error(data?.error || `agent-payload failed (HTTP ${res.status})`);
1128
+ }
1129
+ return data;
1130
+ }
1131
+
1132
+ // ---------- run one agent turn via the JOB QUEUE (the daemon executes it) ----------
1133
+ // The question is enqueued exactly like a web question, so the RUNNING
1134
+ // gonext-cli daemon claims + executes it — its terminal shows the full
1135
+ // [gonext-agent] logs. We poll the job here and stream the persisted chunks live.
1136
+ let following = false;
1137
+ let followAborted = false;
1138
+ let currentJobId = null; // the running turn's jobId — so Ctrl+C can cancel it server-side
1139
+ let cancelRequested = false; // a cancel POST has been sent for the current turn
1140
+ // Interactive command-approval gate: while the agent is paused on a risky command, the
1141
+ // terminal shows a Yes/No list picker (↑/↓ + Enter, Yes preselected). approvalActive
1142
+ // routes keystrokes to the picker; approvalResolve settles the awaiting promise once.
1143
+ let approvalActive = false;
1144
+ let approvalSel = 0; // 0 = Yes (default highlighted), 1 = No
1145
+ let approvalResolve = null;
1146
+ // The active turn's live-status wiper (its local clearStatus), or null between turns. A
1147
+ // picker calls this before drawing so the "Almost done…/thought/token" block is gone
1148
+ // first — otherwise the 120ms ticker keeps redrawing that block on top of the picker and
1149
+ // its relative-cursor math lands on the wrong rows (duplicated/interleaved options).
1150
+ let clearActiveStatus = null;
1151
+ function drawApprovalOptions(redraw) {
1152
+ // Two option lines, redrawn in place. On redraw the cursor sits one row below "No";
1153
+ // step up 2 rows, clear+rewrite both, landing back where we started.
1154
+ const yes = approvalSel === 0 ? green("▸ Yes") : dim(" Yes");
1155
+ const no = approvalSel === 1 ? green("▸ No") : dim(" No");
1156
+ process.stdout.write((redraw ? "\x1b[2A" : "") + "\x1b[K" + yes + "\n\x1b[K" + no + "\n");
1157
+ }
1158
+ function finishApproval(allow) {
1159
+ if (!approvalActive) return;
1160
+ approvalActive = false;
1161
+ // Settle the picker: overwrite the two option lines with a single result line.
1162
+ process.stdout.write("\x1b[2A\x1b[J" + (allow ? green(" ▸ Yes — running it") : red(" ▸ No — skipped")) + "\n");
1163
+ const r = approvalResolve;
1164
+ approvalResolve = null;
1165
+ if (r) r(!!allow);
1166
+ }
1167
+ function onApprovalKey(key) {
1168
+ if (!key) return;
1169
+ if (key.name === "up" || key.name === "down") {
1170
+ approvalSel = key.name === "up" ? 0 : 1; // 2 options: up=Yes, down=No
1171
+ drawApprovalOptions(true);
1172
+ } else if (key.name === "return" || key.name === "enter") {
1173
+ finishApproval(approvalSel === 0);
1174
+ }
1175
+ // Ctrl+C is delivered via readline "SIGINT" (onInterrupt), which also denies — see there.
1176
+ }
1177
+ // Show the picker and resolve to the user's choice. Non-TTY (piped) can't pick → deny
1178
+ // (safe). Auto-denies after ~170s so an absent user never hangs the turn (and stays
1179
+ // under the python side's 180s wait); Enter is instant since Yes is preselected.
1180
+ // The command carries a "__MAXSTEP__::<message>" sentinel when it's the step-budget
1181
+ // continue prompt (task #80) rather than a risky-command approval. That case shows a
1182
+ // 3-option picker (Yes / Yes-don't-ask-again / No); a normal command shows Yes/No.
1183
+ // Returns { allow, dontAsk } — dontAsk is only ever true for the max-step "don't ask
1184
+ // again this session" choice (the caller then stops sending the prompt).
1185
+ const MAXSTEP_PREFIX = "__MAXSTEP__::";
1186
+ const TIMEOUT_PREFIX = "__TIMEOUT__::";
1187
+ const COMPACT_PREFIX = "__COMPACT__::";
1188
+ async function approvalPrompt(command) {
1189
+ // Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
1190
+ // redrawing it (the tick early-returns while a picker is active) — otherwise the block
1191
+ // reappears under the options and corrupts the layout.
1192
+ if (clearActiveStatus) clearActiveStatus();
1193
+ // Context-compaction prompt (task #87 rec#3): the running context got large. Ask whether
1194
+ // to aggressively compact older steps. No answer in 30s = No (keep full detail — the safe
1195
+ // default; the always-on auto-trim still bounds things).
1196
+ if (command.startsWith(COMPACT_PREFIX)) {
1197
+ const label = command.slice(COMPACT_PREFIX.length);
1198
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
1199
+ process.stdout.write("\n" + yellow("🗜 " + label) + dim(" (no answer in 30s = keep full)") + "\n");
1200
+ const idx = await pickFromList(
1201
+ ["Yes, compact older steps", "No, keep full detail"],
1202
+ 0,
1203
+ { timeoutMs: 30000, timeoutIndex: 1 }
1204
+ );
1205
+ return { allow: idx === 0, dontAsk: false };
1206
+ }
1207
+ // Time-budget prompt (task #81): the agent hit its wall-clock budget. Ask whether to
1208
+ // keep waiting; auto-answer No after 60s of no input so the run can't hang forever.
1209
+ if (command.startsWith(TIMEOUT_PREFIX)) {
1210
+ const label = command.slice(TIMEOUT_PREFIX.length);
1211
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
1212
+ process.stdout.write("\n" + yellow("⏳ " + label) + dim(" (no answer in 60s = stop)") + "\n");
1213
+ const idx = await pickFromList(
1214
+ ["Yes, keep waiting", "No, stop now"],
1215
+ 0,
1216
+ { timeoutMs: 60000, timeoutIndex: 1 }
1217
+ );
1218
+ return { allow: idx === 0, dontAsk: false };
1219
+ }
1220
+ const isMax = command.startsWith(MAXSTEP_PREFIX);
1221
+ if (isMax) {
1222
+ const label = command.slice(MAXSTEP_PREFIX.length);
1223
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
1224
+ process.stdout.write("\n" + yellow("⏭ " + label) + "\n");
1225
+ const idx = await pickFromList(
1226
+ ["Yes, keep going", "Yes, and don't ask again this session", "No, stop here"],
1227
+ 0
1228
+ );
1229
+ return { allow: idx === 0 || idx === 1, dontAsk: idx === 1 };
1230
+ }
1231
+ const allow = await new Promise((resolve) => {
1232
+ if (!process.stdin.isTTY) {
1233
+ process.stdout.write(dim(`\n(approval needed for: ${command} — no interactive terminal, skipping)\n`));
1234
+ resolve(false);
1235
+ return;
1236
+ }
1237
+ process.stdout.write(
1238
+ "\n" + yellow("⚠ Allow running this command?") + "\n" + dim(" " + command) + "\n"
1239
+ );
1240
+ approvalSel = 0;
1241
+ approvalResolve = resolve;
1242
+ approvalActive = true;
1243
+ drawApprovalOptions(false);
1244
+ const t = setTimeout(() => finishApproval(false), 170000);
1245
+ const orig = resolve;
1246
+ approvalResolve = (v) => {
1247
+ clearTimeout(t);
1248
+ orig(v);
1249
+ };
1250
+ });
1251
+ return { allow, dontAsk: false };
1252
+ }
1253
+ // Generic arrow-key list picker (↑/↓ + Enter) — used by /server so you navigate instead
1254
+ // of typing a number. Same mechanism as the Yes/No approval picker above, generalized to
1255
+ // N rows. Returns the chosen index (or -1 on Esc/Ctrl+C). Works OUTSIDE a turn, so it also
1256
+ // mutes readline echo/refresh (see the _writeToOutput/_refreshLine guards) via listPickerActive.
1257
+ let listPickerActive = false;
1258
+ let listSel = 0;
1259
+ let listItems = [];
1260
+ let listResolve = null;
1261
+ function drawList(redraw) {
1262
+ const n = listItems.length;
1263
+ const body = listItems
1264
+ .map((it, i) => "\x1b[K" + (i === listSel ? green("▸ " + it) : dim(" " + it)))
1265
+ .join("\n");
1266
+ process.stdout.write((redraw ? `\x1b[${n}A` : "") + body + "\n");
1267
+ }
1268
+ function finishList(index) {
1269
+ if (!listPickerActive) return;
1270
+ listPickerActive = false;
1271
+ process.stdout.write(`\x1b[${listItems.length}A\x1b[J`); // wipe the list block
1272
+ const r = listResolve;
1273
+ listResolve = null;
1274
+ listItems = [];
1275
+ if (r) r(index);
1276
+ }
1277
+ function onListKey(key) {
1278
+ if (!key) return;
1279
+ const n = listItems.length;
1280
+ if (key.name === "up") {
1281
+ listSel = (listSel - 1 + n) % n;
1282
+ drawList(true);
1283
+ } else if (key.name === "down") {
1284
+ listSel = (listSel + 1) % n;
1285
+ drawList(true);
1286
+ } else if (key.name === "return" || key.name === "enter") {
1287
+ finishList(listSel);
1288
+ } else if (key.name === "escape" || (key.ctrl && key.name === "c")) {
1289
+ finishList(-1);
1290
+ }
1291
+ }
1292
+ function pickFromList(items, initialSel = 0, opts = {}) {
1293
+ return new Promise((resolve) => {
1294
+ if (!process.stdin.isTTY || items.length === 0) {
1295
+ resolve(-1);
1296
+ return;
1297
+ }
1298
+ listItems = items;
1299
+ listSel = Math.max(0, Math.min(initialSel, items.length - 1));
1300
+ listResolve = resolve;
1301
+ listPickerActive = true;
1302
+ drawList(false);
1303
+ // Optional auto-resolve after opts.timeoutMs (task #81 time-budget prompt): if the
1304
+ // user doesn't choose in time, finish with opts.timeoutIndex (default -1). Wrap
1305
+ // listResolve so any real selection clears the timer.
1306
+ if (opts && opts.timeoutMs > 0) {
1307
+ const to = setTimeout(
1308
+ () => finishList(opts.timeoutIndex ?? -1),
1309
+ opts.timeoutMs
1310
+ );
1311
+ const orig = listResolve;
1312
+ listResolve = (v) => {
1313
+ clearTimeout(to);
1314
+ orig(v);
1315
+ };
1316
+ }
1317
+ });
1318
+ }
1319
+
1320
+ // Cancel whichever approval picker is currently showing (Yes/No or the N-row list),
1321
+ // resolving it as a DENY. Used when the job ends server-side while we're still waiting
1322
+ // for the user, so the picker promise never leaks and no late choice is sent (#110).
1323
+ function cancelActivePicker() {
1324
+ if (approvalActive) finishApproval(false);
1325
+ if (listPickerActive) finishList(-1);
1326
+ }
1327
+
1328
+ // Show an approval picker, but RACE it against the job going terminal. The old code
1329
+ // awaited the picker INLINE inside the poll loop, so while the user thought, the REPL
1330
+ // stopped polling and couldn't notice that python had already given up (its own approval
1331
+ // deadline) and completed the job — the user's late "Yes" then hit a finished job and was
1332
+ // silently lost (#110). Here a background watcher polls the job; if it goes
1333
+ // completed/failed/cancelled first, we dismiss the picker and report `ended:true` so the
1334
+ // caller skips the POST and lets the normal terminal-status handling run.
1335
+ async function approvalPromptRacingJob(command) {
1336
+ let watching = true;
1337
+ const watcher = (async () => {
1338
+ while (watching) {
1339
+ await sleep(1000);
1340
+ if (!watching) return null;
1341
+ try {
1342
+ const r = await fetch(`${apiBase}/api/worker/jobs/${jobIdForApprovalRace}`, {
1343
+ headers: { "X-Worker-Key": workerKey },
1344
+ });
1345
+ if (!r.ok) continue;
1346
+ const j = await r.json().catch(() => ({}));
1347
+ if (
1348
+ j?.jobStatus === "completed" ||
1349
+ j?.jobStatus === "failed" ||
1350
+ j?.jobStatus === "cancelled"
1351
+ ) {
1352
+ return "ended";
1353
+ }
1354
+ } catch {
1355
+ /* transient poll error — keep watching */
1356
+ }
1357
+ }
1358
+ return null;
1359
+ })();
1360
+ const picker = approvalPrompt(command).then((r) => ({ kind: "answer", ...r }));
1361
+ const raced = await Promise.race([
1362
+ picker,
1363
+ watcher.then((v) => (v === "ended" ? { kind: "ended" } : new Promise(() => {}))),
1364
+ ]);
1365
+ watching = false;
1366
+ if (raced.kind === "ended") {
1367
+ cancelActivePicker(); // unblock the picker promise so it doesn't leak
1368
+ await picker.catch(() => {}); // let it settle after the cancel
1369
+ return { ended: true, allow: false, dontAsk: false };
1370
+ }
1371
+ // The user answered first — stop the watcher and return their choice.
1372
+ return { ended: false, allow: raced.allow, dontAsk: raced.dontAsk };
1373
+ }
1374
+ // The jobId the approval race should watch — set by runAgentTurn before it can prompt.
1375
+ let jobIdForApprovalRace = "";
1376
+
1377
+ // Per-session coding-model override chosen via /model (empty = use the account default).
1378
+ // Sent to /agent-ask as codingModelOverride; the API validates it against the whitelist.
1379
+ // Task #123: with several backends configured this is a QUALIFIED id ("<kind>::<model>"),
1380
+ // which resolves to a whole backend — URL, kind and API key — not just a model name.
1381
+ let sessionCodingModel = "";
1382
+ // The friendly model name behind sessionCodingModel, for display only: the token footer
1383
+ // must never show a raw "<kind>::<model>" wire id. Empty = running the account default.
1384
+ let sessionCodingLabel = "";
1385
+ // The default coding model id (from the startup agent-payload probe) — shown in the
1386
+ // per-turn token footer next to the GLOBAL output total so it's clear which model that
1387
+ // lifetime count belongs to. The /model override (sessionCodingModel) wins when set.
1388
+ let defaultCodingModel = "";
1389
+ // Task #125: the coding-server URL of the backend in effect this session — the /model
1390
+ // pick's backend when one is chosen, else the account default. Sent to the output-token
1391
+ // endpoints so the GLOBAL lifetime total is keyed to the RIGHT backend (a /model switch to
1392
+ // another backend must not keep showing the previous backend's total). Empty = let the API
1393
+ // fall back to the account-default URL from settings (older behaviour).
1394
+ let sessionCodingUrl = "";
1395
+ // The account-default backend's URL (from the startup probe's codingChoices), used when no
1396
+ // /model override is active. Set once at startup alongside defaultCodingModel.
1397
+ let defaultCodingUrl = "";
1398
+ // Auto-test mode (/test-auto): when on, the agent verifies its work (build/run/curl/…)
1399
+ // and fixes → re-tests until it passes before finishing. Default off; remembered per
1400
+ // folder in the session file; sent to /agent-ask as autoTest so python drives the loop.
1401
+ let sessionTestAuto = false;
1402
+ // RAG storage backend for THIS folder (task #97): "local" (index on disk under ~/.gonext,
1403
+ // per-folder, no S3 needed) | "cloud" (the user's S3 bucket) | null (never chosen → ask once
1404
+ // at startup when RAG is enabled). Remembered per folder; sent to /agent-ask as ragMode.
1405
+ let sessionRagMode = null;
1406
+ // Step-budget opt-out (task #80): set once the user picks "yes, don't ask again" at a
1407
+ // max-step prompt. Session-scoped (not persisted per folder); sent to /agent-ask each turn
1408
+ // so the agent auto-extends past the step budget without prompting again.
1409
+ let alwaysExtendOnMaxStep = false;
1410
+ // Deploy target chosen via /server (task #69): {name, host, user} or null. Session-scoped;
1411
+ // sent to /agent-ask as deployServer so the agent deploys to this host with KEY auth.
1412
+ let selectedServer = null;
1413
+ // Task #84: peak single-request agent-code-model token count for THIS workspace, kept
1414
+ // as a max across turns and persisted per folder in the session file. Shown in orange
1415
+ // next to the live token count.
1416
+ let sessionMaxCodeTokens = 0;
1417
+ // Task #104: GLOBAL lifetime OUTPUT-token total for (this user + the agent coding-model
1418
+ // API URL), fetched from the API. Shown with a ↓ next to the input/max counts. Updated
1419
+ // after each turn (the API returns the new total) and refreshed at startup.
1420
+ let globalOutputTokens = 0;
1421
+
1422
+ // While a turn runs, mute readline's own echo/redraws so keystrokes can't corrupt the
1423
+ // live status display and there's no way to type a new question — only Ctrl+C works.
1424
+ // Same _writeToOutput override password prompts use; our UI (prompt, status, bullets)
1425
+ // writes to stdout directly, so it's unaffected — only readline's echo is swallowed.
1426
+ const _origWriteToOutput = rl._writeToOutput ? rl._writeToOutput.bind(rl) : null;
1427
+ rl._writeToOutput = (s) => {
1428
+ // slashOverlayActive: the slash-command overlay owns drawing (input line + menu), so
1429
+ // readline's own echo must be swallowed or it fights the overlay (bug #109).
1430
+ if (following || listPickerActive || slashOverlayActive) return;
1431
+ // secretInput (task #127): the user is typing an API key — swallow the echo so it never
1432
+ // appears in the terminal or scrollback.
1433
+ if (secretInput) return;
1434
+ if (_origWriteToOutput) _origWriteToOutput(s);
1435
+ else process.stdout.write(s);
1436
+ };
1437
+ // _writeToOutput only mutes echoed TEXT; readline's line refresh writes cursor-position
1438
+ // + erase escapes (e.g. ESC[1G, ESC[0J) straight to output on every keypress, which
1439
+ // would corrupt our live status display. No-op it during a turn so a keypress produces
1440
+ // ZERO terminal output.
1441
+ const _origRefreshLine = rl._refreshLine ? rl._refreshLine.bind(rl) : null;
1442
+ rl._refreshLine = () => {
1443
+ // Suppress readline's line refresh (which does clearScreenDown → would wipe our menu)
1444
+ // while the slash overlay owns the screen. We redraw the input line ourselves (#109).
1445
+ if (following || listPickerActive || slashOverlayActive) return;
1446
+ if (secretInput) return; // don't redraw the typed key (task #127)
1447
+ if (_origRefreshLine) _origRefreshLine();
1448
+ };
1449
+
1450
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1451
+
1452
+ /** Strip <think> markers for live display, and whole think blocks for the answer. */
1453
+ const stripThinkTags = (s) => s.replace(/<\/?think>/gi, "");
1454
+ const answerFrom = (s) =>
1455
+ s
1456
+ .replace(/<think>[\s\S]*?<\/think>/gi, "")
1457
+ .replace(/<think>[\s\S]*$/i, "")
1458
+ .trim();
1459
+
1460
+ // --- Live-thought normalization (task "agent thought normalizing issue") ---------------
1461
+ // The blinking status line names the model's current Thought. But the model often emits
1462
+ // <code> with NO "Thought:" prose (or degenerates into a runaway), so the raw stream is
1463
+ // a create_file(...) blob / escaped CSS — which used to render verbatim on the line
1464
+ // ("◐ , #94a3b8);\n -webkit-background-clip…"). Fix: show a genuine thought ONLY when the
1465
+ // model actually wrote prose; otherwise name the TOOL it's calling ("Writing App.js");
1466
+ // otherwise show nothing (the line falls back to "Thinking…"). Never raw code/CSS.
1467
+ const _baseName = (p) => (String(p || "").split(/[\\/]/).pop() || "").trim();
1468
+ const THOUGHT_TOOL_LABELS = {
1469
+ create_file: (a) => `Writing ${_baseName(a) || "a file"}`,
1470
+ create_folder: (a) => `Creating ${_baseName(a) || "a folder"}`,
1471
+ edit_lines: (a) => `Editing ${_baseName(a) || "a file"}`,
1472
+ edit_file: (a) => `Editing ${_baseName(a) || "a file"}`,
1473
+ read_file_lines: (a) => `Reading ${_baseName(a) || "a file"}`,
1474
+ read_text_file: (a) => `Reading ${_baseName(a) || "a file"}`,
1475
+ list_dir: (a) => `Listing ${_baseName(a) || "files"}`,
1476
+ grep_repo: () => "Searching the code",
1477
+ run_command: (a) => `Running ${a || "a command"}`,
1478
+ stop_server: () => "Stopping the server",
1479
+ deploy_web: () => "Deploying",
1480
+ fetch_url: (a) => `Fetching ${_baseName(a) || "a page"}`,
1481
+ web_search: () => "Searching the web",
1482
+ http_request: () => "Calling an API",
1483
+ create_pdf: () => "Building a PDF",
1484
+ rag_search: () => "Searching the knowledge base",
1485
+ rag_index: () => "Indexing the knowledge base",
1486
+ download_file: () => "Downloading a file",
1487
+ unzip_file: () => "Unzipping",
1488
+ send_email: () => "Preparing an email",
1489
+ open_url: (a) => `Opening ${a || "a link"}`,
1490
+ final_answer: () => "Composing the answer",
1491
+ };
1492
+ // Does this candidate look like CODE / a data blob rather than a human thought? (Escaped
1493
+ // newlines, braces, hex colors, -webkit-, a leading function call, or too few spaces.)
1494
+ const _thoughtLooksCodey = (s) =>
1495
+ /\\n|[{}]|=>|="|#[0-9a-fA-F]{3,6}\b|-webkit-|;\s*$|^\s*[<([]/.test(s) ||
1496
+ /^\s*[a-z_]\w*\s*\(/i.test(s) ||
1497
+ (s.length > 24 && (s.split(" ").length - 1) / s.length < 0.06);
1498
+ // Raw in-fence stream → a short, meaningful status: a real Thought clause if the model
1499
+ // wrote one, else a friendly label from the tool it's calling, else "" (→ "Thinking…").
1500
+ const normalizeThought = (buf) => {
1501
+ const t = String(buf || "");
1502
+ const ci = t.search(/<code[\s>]/i);
1503
+ const head = ci >= 0 ? t.slice(0, ci) : t;
1504
+ // 1) a genuine Thought prose line (before the code) — only if it isn't itself code AND
1505
+ // reads like an actual clause. A streaming token boundary can leave the first
1506
+ // non-empty line as a single bare word (e.g. "string", when the model is rambling
1507
+ // about "multi-line strings"); shown alone on the blinking line it's meaningless and,
1508
+ // because liveThought only updates on a truthy value, it can FREEZE there through a
1509
+ // long silent prompt-eval ("string… (777s)"). Require ≥2 words and a little length so
1510
+ // a lone fragment falls through to the tool label / "Thinking…" instead.
1511
+ const prose = head
1512
+ .replace(/^\s*Thought:\s*/i, "")
1513
+ .split(/\n/)
1514
+ .map((x) => x.trim())
1515
+ .find(Boolean);
1516
+ const proseIsClause = !!prose && prose.length >= 6 && prose.trim().split(/\s+/).length >= 2;
1517
+ if (proseIsClause && !_thoughtLooksCodey(prose)) return prose.slice(0, 100);
1518
+ // 2) otherwise name the tool being called (from the code part).
1519
+ const codePart = ci >= 0 ? t.slice(ci) : t;
1520
+ const m = /(?:<code>\s*)?\b([a-z_]\w*)\s*\(\s*(?:path\s*=\s*)?["']?([^"'\n,)]*)/i.exec(codePart);
1521
+ if (m && THOUGHT_TOOL_LABELS[m[1]]) return THOUGHT_TOOL_LABELS[m[1]](m[2].trim()).slice(0, 100);
1522
+ // 3) nothing human to show.
1523
+ return "";
1524
+ };
1525
+
1526
+ // The FULLER thought (task #96): the same Thought prose normalizeThought summarizes, but
1527
+ // NOT clipped to one clause/100 chars — collapsed to a single spaced string and capped so
1528
+ // the expanded (mouse-click) view has a few lines to show. Returns "" when the live line is
1529
+ // only a tool label (nothing extra to expand). Bounded by fenceThought's own 400-char cap.
1530
+ const thoughtFull = (buf) => {
1531
+ const t = String(buf || "");
1532
+ const ci = t.search(/<code[\s>]/i);
1533
+ const head = (ci >= 0 ? t.slice(0, ci) : t)
1534
+ .replace(/^\s*Thought:\s*/i, "")
1535
+ .replace(/\s+/g, " ")
1536
+ .trim();
1537
+ // Only worth expanding when there's a genuine prose clause (same gate as normalizeThought
1538
+ // path 1) — a bare fragment or pure code has nothing meaningful to unfold.
1539
+ const first = head.split(/\s+/).length;
1540
+ if (head.length < 6 || first < 2 || _thoughtLooksCodey(head)) return "";
1541
+ return head.slice(0, 400);
1542
+ };
1543
+
1544
+ // Word-wrap a plain (ANSI-free) string into at most `maxLines` lines of width ≤ `width`,
1545
+ // appending an ellipsis to the last line when text was dropped (task #96 expand view).
1546
+ const wrapThought = (s, width, maxLines) => {
1547
+ const words = String(s || "").split(/\s+/).filter(Boolean);
1548
+ const lines = [];
1549
+ let cur = "";
1550
+ let truncated = false;
1551
+ for (let i = 0; i < words.length; i++) {
1552
+ const w = words[i];
1553
+ const next = cur ? cur + " " + w : w;
1554
+ if (next.length <= width) {
1555
+ cur = next;
1556
+ } else {
1557
+ if (cur) lines.push(cur);
1558
+ cur = w;
1559
+ if (lines.length >= maxLines) { truncated = true; cur = ""; break; }
1560
+ }
1561
+ }
1562
+ if (cur && lines.length < maxLines) lines.push(cur);
1563
+ else if (cur) truncated = true;
1564
+ if (truncated && lines.length) {
1565
+ const last = lines[maxLines - 1] ?? lines[lines.length - 1];
1566
+ const room = Math.max(1, width - 1);
1567
+ lines[lines.length - 1] = (last.length > room ? last.slice(0, room) : last).replace(/\s+$/, "") + "…";
1568
+ }
1569
+ return lines;
1570
+ };
1571
+
1572
+ async function runAgentTurn(history) {
1573
+ const res = await fetch(`${apiBase}/api/worker/agent-ask`, {
1574
+ method: "POST",
1575
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
1576
+ // cwd = this terminal's folder → agent puts download/unzip output here (when it's
1577
+ // a registered workspace). codingModelOverride = the /model choice for this session.
1578
+ body: JSON.stringify({
1579
+ messages: history,
1580
+ cwd: resolve(process.cwd()),
1581
+ // The terminal can show a Yes/No picker → the agent PAUSES on a risky command and
1582
+ // asks instead of hard-blocking it (interactive command-approval gate).
1583
+ interactiveApproval: true,
1584
+ // Step-budget opt-out (task #80): once the user chose "don't ask again", auto-extend
1585
+ // past the step budget without prompting.
1586
+ ...(alwaysExtendOnMaxStep ? { alwaysExtendOnMaxStep: true } : {}),
1587
+ // Auto-test mode (/test-auto): the agent verifies its work and fixes until it passes.
1588
+ ...(sessionTestAuto ? { autoTest: true } : {}),
1589
+ // RAG storage backend for this folder (task #97): local disk vs cloud/S3. Only sent
1590
+ // once chosen; absent → the API/python default (cloud), preserving old behavior.
1591
+ ...(sessionRagMode ? { ragMode: sessionRagMode } : {}),
1592
+ // Deploy target chosen via /server — host/user only (no secret), for key-auth deploys.
1593
+ ...(selectedServer ? { deployServer: selectedServer } : {}),
1594
+ ...(sessionCodingModel ? { codingModelOverride: sessionCodingModel } : {}),
1595
+ }),
1596
+ });
1597
+ const data = await res.json().catch(() => ({}));
1598
+ if (!res.ok) {
1599
+ if (res.status === 404) {
1600
+ throw new Error(
1601
+ "the API does not have POST /api/worker/agent-ask yet — deploy the latest API."
1602
+ );
1603
+ }
1604
+ throw new Error(data?.error || `agent-ask failed (HTTP ${res.status})`);
1605
+ }
1606
+ const jobId = data?.jobId;
1607
+ if (!jobId) throw new Error("agent-ask returned no jobId.");
1608
+
1609
+ following = true;
1610
+ followAborted = false;
1611
+ currentJobId = jobId;
1612
+ jobIdForApprovalRace = jobId; // so an approval picker can watch THIS job for early end (#110)
1613
+ cancelRequested = false;
1614
+ const startedAt = Date.now();
1615
+ let shownChars = 0;
1616
+ let lastApprovalId = null; // the last command-approval request we've already answered
1617
+ let carry = ""; // trailing partial content line held until its newline arrives
1618
+ let statusShown = false; // a transient status line is currently on screen
1619
+ let statusLines = 2; // how many rows the status block currently occupies (grows when expanded)
1620
+ let warnedPending = false;
1621
+ // Transient poll failures (a network blip or a 5xx like the API's momentary "Worker
1622
+ // key lookup failed." — a cold Mongo connection, NOT a bad key) must NOT kill a
1623
+ // long-running turn. Tolerate a run of them; only give up after too many in a row.
1624
+ let pollFailures = 0;
1625
+ const MAX_POLL_FAILURES = 6; // ~6 tries with backoff before conceding the follow
1626
+ let jobRunning = false; // only tick "thinking…" once the worker has claimed the job
1627
+ let answerShownLive = false; // plain-reply content already printed — don't re-print it
1628
+ // True once we've SPECIFICALLY seen "→ Chat reply" (the plain-reply/trivial-chat
1629
+ // branch), as opposed to "→ Agent mode…" (the tool-use branch). Only the plain-reply
1630
+ // path streams its answer via answer_stream with NO heartbeat/tool-summary/code lines
1631
+ // ever interleaved — so only THERE is it safe to partial-flush trailing content live
1632
+ // (see carryFlushedLen below); the agent path's code/tool-call lines must stay buffered
1633
+ // until a full line is known, or we'd risk showing part of something we meant to hide.
1634
+ let plainReplyFlow = false;
1635
+ // Exact answer characters printed live in the plain-reply flow — lets the completed
1636
+ // poll figure out how much of the FINAL answer is still unshown even when resultText
1637
+ // was replaced (not extended) on completion. See the completed-branch recovery below.
1638
+ let liveAnswer = "";
1639
+ // Running total of prompt tokens sent to the agent CODE model this turn (task #83),
1640
+ // read from the job doc each poll. Rendered on the thinking line after the playful word.
1641
+ let latestCodeTokens = 0;
1642
+ // Task #104: this turn's OUTPUT-token total, read from the job doc each poll. Folded into
1643
+ // the lifetime per-workspace + global counters once the turn completes.
1644
+ let latestOutputTokens = 0;
1645
+ let outputTokensPosted = false; // guard: increment the lifetime counters once per turn
1646
+ // First few KB of the raw stream as consumed — a cheap fingerprint to verify the
1647
+ // completed resultText really EXTENDS what we streamed (an old worker replaces it
1648
+ // with the shorter bare answer, which must not be sliced by shownChars).
1649
+ let seenRaw = "";
1650
+
1651
+ // --- Live in-progress ("blinking") bullet -------------------------------------------
1652
+ // The agent's raw Thought/code stream is SUPPRESSED (see the ~~~-fence handling in
1653
+ // consume) — the terminal shows only a clean list of action bullets. While a step is
1654
+ // being thought about / run, we show ONE in-place blinking bullet that counts up —
1655
+ // "◐ working… (7s)" — instead of dumping the model's rambling reasoning and stamping a
1656
+ // "✔ completed thought" on every token-stream micro-pause (the reported clutter).
1657
+ const QUIET_MS = 700; // stream idle this long ⇒ we're waiting on the model
1658
+ const STALE_THOUGHT_MS = 25000; // no new tokens this long ⇒ the current Thought clause is stale (#95 A)
1659
+ let lastContentAt = Date.now();
1660
+ let thinking = false;
1661
+ let thinkingSince = 0;
1662
+ let thinkWord = pickWord(); // playful word shown UNDER the in-progress line (flavor)
1663
+ let lastWordBucket = -1; // 12s window index → rotate the word once per window (task: 30-spinner)
1664
+ let almostDone = false; // set from the worker's "…almost completed thinking…" heartbeat
1665
+ let runningCmd = null; // the command currently executing (from a "Running → …" event)
1666
+ // The model's CURRENT Thought (task #64): while it streams its reasoning inside the
1667
+ // hidden ~~~ fence, show a one-clause summary on the blinking line so a 300s CPU-bound
1668
+ // step reads as "◑ Adding the nav links to app.component.html… (52s)" instead of a
1669
+ // random word. `fenceThought` accumulates the raw in-fence text for the current step.
1670
+ let liveThought = "";
1671
+ let fenceThought = "";
1672
+ // Task #96: the FULLER version of the current thought (for the click-to-expand view) and
1673
+ // whether the user has clicked to expand it. Both reset when the step's Thought changes.
1674
+ let fullLiveThought = "";
1675
+ let thoughtExpanded = false;
1676
+ let hoverRow = -1; // last mouse viewport row (from motion events) → highlight when it's on the thought line
1677
+ let thoughtRow = -1; // absolute viewport row of the thought line (line 1), learned via \x1b[6n
1678
+ let lastDsrAt = 0; // throttle the cursor-position probe
1679
+ const thinkSecs = () => Math.max(1, Math.round((Date.now() - thinkingSince) / 1000));
1680
+
1681
+ // The status is normally TWO in-place lines — the in-progress action, then the playful
1682
+ // word under it — but grows by a few grey lines when the thought is expanded (task #96).
1683
+ // Cursor sits at the end of the LAST line, so clearing = wipe it, then move-up-and-wipe
1684
+ // for each line above, leaving the cursor back at the status's origin for a redraw.
1685
+ const clearStatus = () => {
1686
+ if (!statusShown) return;
1687
+ let seq = "\r\x1b[K";
1688
+ for (let i = 1; i < statusLines; i++) seq += "\x1b[1A\r\x1b[K";
1689
+ process.stdout.write(seq);
1690
+ statusShown = false;
1691
+ };
1692
+ clearActiveStatus = clearStatus; // let pickers wipe this turn's status block before they draw
1693
+
1694
+ const tick = () => {
1695
+ if (!following || followAborted || !jobRunning) return;
1696
+ if (approvalActive || listPickerActive) return; // a picker owns the screen — don't draw over it
1697
+ if (Date.now() - lastContentAt < QUIET_MS) return; // tokens still flowing
1698
+ // A plain-reply answer streams onto a line with NO trailing newline until it's fully
1699
+ // done — overwriting the ticker there corrupts the visible answer mid-word. Stay
1700
+ // silent once any of THIS line has already been shown (see carryFlushedLen).
1701
+ if (plainReplyFlow && carryFlushedLen > 0) return;
1702
+ // Count from when this phase went quiet, so the seconds reflect the current wait/run.
1703
+ if (!thinking) {
1704
+ thinking = true; thinkingSince = lastContentAt; thinkWord = pickWord(); lastWordBucket = 0;
1705
+ }
1706
+ const now = Date.now();
1707
+ // Age out a stale Thought clause: if the model has produced NO new stream content for a
1708
+ // while (e.g. a 150s+ silent prompt-eval on a huge context), the last clause is no longer
1709
+ // "what it's doing now" — drop it so the line falls back to the playful word / "Thinking…"
1710
+ // instead of freezing on an old thought while the timer climbs ("string… (777s)", #95 A).
1711
+ if (liveThought && now - lastContentAt > STALE_THOUGHT_MS) {
1712
+ liveThought = ""; fullLiveThought = ""; thoughtExpanded = false; // #96: nothing to expand once stale
1713
+ }
1714
+ const secs = thinkSecs();
1715
+ // Rotate the playful word once per 12s window (guarded so the fast ticker doesn't
1716
+ // re-roll it many times within the same second).
1717
+ const wordBucket = Math.floor((now - thinkingSince) / 12000);
1718
+ if (wordBucket !== lastWordBucket) { lastWordBucket = wordBucket; thinkWord = pickWord(); }
1719
+ // Spinner: STYLE rotates ~every 8s, FRAME advances ~every 120ms, color cycles ~every
1720
+ // 2.5s — all off the wall clock so they keep moving smoothly across phases.
1721
+ const frames = SPINNERS[Math.floor(now / 8000) % SPINNERS.length];
1722
+ const glyph = frames[Math.floor(now / 120) % frames.length];
1723
+ const wc = WAIT_COLORS_256[Math.floor(now / 2500) % WAIT_COLORS_256.length];
1724
+ // Line 1 = WHAT is happening now: the running command, else the phase (Thinking /
1725
+ // Almost done). Line 2 = the playful word (flavor). Truncate so neither wraps.
1726
+ // Priority: a live command > the model's current Thought clause (task #64, so a
1727
+ // long CPU-bound step reads as what it's actually reasoning about, not "Thinking")
1728
+ // > the "almost done" heartbeat > the bare fallback.
1729
+ const max = Math.max(24, (process.stdout.columns || 80) - 14);
1730
+ const fit = (s) => (s.length > max ? s.slice(0, max - 1) + "…" : s);
1731
+ // Task #104: global (user + coding-URL) OUTPUT-token total, ↓ = generated. Add THIS
1732
+ // turn's in-progress output (latestOutputTokens) to the persisted global so the live
1733
+ // count climbs in real time and matches the web; it's reset to 0 once the turn's total
1734
+ // is POSTed into globalOutputTokens (so it's never double-counted).
1735
+ const liveOutputTotal = globalOutputTokens + latestOutputTokens;
1736
+ const tokLabel =
1737
+ dim(` · ${fmtTokens(latestCodeTokens)} tok`) +
1738
+ orange(` · ${fmtTokens(sessionMaxCodeTokens)} max`) +
1739
+ (liveOutputTotal > 0 ? cyan(` · ↓ ${fmtTokens(liveOutputTotal)}`) : "");
1740
+ // Task #96: the thought line is "clickable" only when the fuller thought has MORE text than
1741
+ // fits on line 1 (a live command / bare "Thinking" / a thought that already fits has nothing
1742
+ // to reveal). Line 1 (what's happening now): a live command > the model's current Thought >
1743
+ // "almost done" > "Thinking". When clickable we drive line 1 from the SAME fuller string, so
1744
+ // the grey expansion CONTINUES where line 1 was cut off instead of repeating the head.
1745
+ const clickable = !!fullLiveThought && !runningCmd && fullLiveThought.length > max;
1746
+ let label, rest;
1747
+ if (clickable) {
1748
+ // Split at a WORD boundary near the line-1 width so nothing is cut mid-word: the last
1749
+ // space at/before max-1 (unless that snaps back too far into the line — then hard-cut).
1750
+ // Line 1 shows the head; the grey expansion CONTINUES from the same point (no dup, no
1751
+ // split word).
1752
+ const sp = fullLiveThought.lastIndexOf(" ", max - 1);
1753
+ const cut = sp >= Math.floor(max * 0.6) ? sp : max - 1;
1754
+ label = `${fullLiveThought.slice(0, cut).replace(/\s+$/, "")}… (${secs}s)`;
1755
+ rest = fullLiveThought.slice(cut).trim();
1756
+ } else {
1757
+ const primary = runningCmd
1758
+ ? `Running ${runningCmd}`
1759
+ : liveThought || (almostDone ? "Almost done" : "Thinking");
1760
+ label = `${fit(primary)}… (${secs}s)`;
1761
+ rest = "";
1762
+ }
1763
+ // When clicked, unfold the remainder as up to 4 wrapped GREY lines BETWEEN the thought and
1764
+ // the playful-word line.
1765
+ const expLines = thoughtExpanded && rest ? wrapThought(rest, max, 4) : [];
1766
+ const drawn = 2 + expLines.length;
1767
+ const rows = process.stdout.rows || 24;
1768
+ // Hover-highlight only the THOUGHT line (line 1) — its exact viewport row comes from the
1769
+ // \x1b[6n probe below (`thoughtRow`); fall back to the viewport-bottom assumption only when
1770
+ // the terminal never answered the probe.
1771
+ const tRow = thoughtRow >= 1 ? thoughtRow : rows - drawn + 1;
1772
+ const hovering = clickable && hoverRow === tRow;
1773
+ clearStatus();
1774
+ // Right after clearing, the cursor sits at the status block's TOP-LEFT = the thought line's
1775
+ // row. Probe for it (throttled) so hover/click hit-testing is exact wherever the block is.
1776
+ if (process.stdin.isTTY && now - lastDsrAt > 400) { lastDsrAt = now; process.stdout.write("\x1b[6n"); }
1777
+ // Line 1: green ● + green phase/seconds (matches the web's green streaming label), or the
1778
+ // underlined bright-green hover style when the mouse is over a clickable thought. Then the
1779
+ // GREY expanded lines (if any), then LAST the SPINNER + playful word + token labels
1780
+ // (task #83/#84), e.g. " ⠙ Caffeinating… · 12.3k tok · 8.1k max".
1781
+ let out = `${green(BULLET)} ${hovering ? hoverThought(label) : green(label)}`;
1782
+ for (const wl of expLines) out += "\n " + dim(wl);
1783
+ out += "\n" + ` ${color256(wc, `${glyph} ${thinkWord}…`)}${tokLabel}`;
1784
+ process.stdout.write(out);
1785
+ statusLines = drawn;
1786
+ statusShown = true;
1787
+ };
1788
+ // 120ms so the spinner animates smoothly (the frame math above uses the wall clock, so
1789
+ // seconds still tick once/sec). Only redraws when the model has gone quiet >QUIET_MS.
1790
+ const ticker = setInterval(tick, 120);
1791
+
1792
+ // --- Hover + click on the thinking line (task #96) -------------------------------------
1793
+ // HOVER: motion events keep `hoverRow`; the tick highlights the thought line (underlined
1794
+ // bright-green "link") whenever the mouse is on its row. CLICK: a left-click on the status
1795
+ // block toggles the fuller thought (3-4 grey lines). The block sits at the bottom of the
1796
+ // viewport (new output keeps scrolling it there), so we hit-test rows against the bottom
1797
+ // `statusLines` rows. Mouse reporting is on only for the turn and always turned back off in
1798
+ // the finally + on process exit, so the user's shell is never left in mouse-capture mode.
1799
+ const onStatusClick = (row) => {
1800
+ if (!statusShown) return;
1801
+ const rows = process.stdout.rows || 24;
1802
+ const top = thoughtRow >= 1 ? thoughtRow : rows - statusLines + 1; // thought line row
1803
+ // Toggle only on the thought line + its grey expansion — NOT the last row (the playful
1804
+ // word / token line), so a click there doesn't expand what it isn't part of.
1805
+ if (row < top || row > top + statusLines - 2) return;
1806
+ // Only meaningful when there IS a fuller thought to reveal; otherwise ignore the click
1807
+ // so we don't flicker an empty expand.
1808
+ if (!thoughtExpanded && !(fullLiveThought && !runningCmd)) return;
1809
+ thoughtExpanded = !thoughtExpanded;
1810
+ clearStatus(); // next tick (≤120ms) redraws at the new size; keeps cursor math correct
1811
+ };
1812
+ const onMouseData = (chunk) => {
1813
+ if (!following) return;
1814
+ const s = chunk.toString("latin1");
1815
+ if (s.indexOf("\x1b[") === -1) return; // fast reject: no CSI (mouse report OR cursor reply) here
1816
+ // Cursor-position reply → the thought line's absolute row (see the \x1b[6n probe in tick).
1817
+ DSR_RE.lastIndex = 0;
1818
+ let d;
1819
+ while ((d = DSR_RE.exec(s))) thoughtRow = parseInt(d[1], 10);
1820
+ MOUSE_RE.lastIndex = 0;
1821
+ let m;
1822
+ while ((m = MOUSE_RE.exec(s))) {
1823
+ const btn = parseInt(m[1], 10);
1824
+ const row = parseInt(m[3], 10);
1825
+ const isPress = m[4] === "M";
1826
+ if (btn & 32) { hoverRow = row; continue; } // MOTION (hover/drag) → track for the highlight
1827
+ if (isPress && (btn & 0b11) === 0 && (btn & 64) === 0) onStatusClick(row); // left click → expand
1828
+ }
1829
+ };
1830
+ if (process.stdin.isTTY) process.stdin.on("data", onMouseData);
1831
+ enableMouse();
1832
+
1833
+ // Called right before any real (bullet/edit-card/answer) content prints: drop the
1834
+ // status lines so content lands cleanly, and end the current phase (so the next phase's
1835
+ // seconds count fresh, and any running command is considered finished).
1836
+ const onContent = () => {
1837
+ clearStatus();
1838
+ thinking = false;
1839
+ runningCmd = null;
1840
+ // A concrete action landed → the Thought that led to it is spent; clear it so the
1841
+ // next step's blinking line starts from "Thinking", not last step's stale clause.
1842
+ liveThought = "";
1843
+ fenceThought = "";
1844
+ fullLiveThought = ""; // #96: the fuller thought is spent too…
1845
+ thoughtExpanded = false; // …and each new thinking phase starts collapsed.
1846
+ lastDsrAt = 0; // …content just scrolled → re-probe the thought row next tick.
1847
+ lastContentAt = Date.now();
1848
+ };
1849
+
1850
+ // Split newly-arrived text into lines: heartbeats + ~~~ fences + the post-tool-call step
1851
+ // summary ("tool(...) | → result", redundant with the daemon's own terminal) are
1852
+ // swallowed; the model's <code>/</code> TAG lines are hidden but the code body between
1853
+ // them prints in a distinct grey (not dim, so it stands out from "Thought:" prose);
1854
+ // blank lines keep spacing; everything else commits dim to scrollback.
1855
+ let inCode = false;
1856
+ let inEditCard = false; // inside a colored edit-card block (see isEditCardHeader)
1857
+ // The worker wraps the raw model token stream (Thought prose + code) in ~~~ fences;
1858
+ // the concrete ACTION events (tool steps, edit cards) are emitted OUTSIDE them. We
1859
+ // suppress everything IN a fence — the rambling Thought and redundant raw code (the
1860
+ // edit card already shows real changes) — and render the out-of-fence actions as
1861
+ // bullets. This is the whole "action bullets only" declutter (task #41).
1862
+ let inStreamFence = false;
1863
+ let lastWasBlank = true; // suppresses a leading blank line and collapses repeats
1864
+ // Chars of the CURRENT trailing (newline-less) `carry` line already flushed live — lets
1865
+ // a long plain-reply answer stream character-by-character instead of waiting for a "\n"
1866
+ // that might not arrive until the very end (most short replies have none at all).
1867
+ let carryFlushedLen = 0;
1868
+ const consume = (chunk) => {
1869
+ carry += stripThinkTags(chunk);
1870
+ let nl;
1871
+ while ((nl = carry.indexOf("\n")) >= 0) {
1872
+ const line = carry.slice(0, nl);
1873
+ carry = carry.slice(nl + 1);
1874
+ const alreadyFlushed = carryFlushedLen;
1875
+ carryFlushedLen = 0; // reset — `carry` now starts a fresh pending line
1876
+ if (isHeartbeatLine(line)) {
1877
+ // Heartbeats are swallowed (the local ticker drives the display), but they
1878
+ // still carry ONE bit we want: once the worker's word is "…almost completed
1879
+ // thinking…", this model call is past prompt-eval and producing output — flip
1880
+ // the local ticker to the same wording. A plain random-word heartbeat means
1881
+ // we're back to waiting (a fresh step's prompt-eval), so clear the flag.
1882
+ almostDone = /almost/i.test(line);
1883
+ continue;
1884
+ }
1885
+ // A fence opening starts a fresh step's raw stream → reset the harvested Thought
1886
+ // so the blinking line doesn't carry last step's clause into this one.
1887
+ if (isFenceLine(line)) {
1888
+ inStreamFence = !inStreamFence;
1889
+ if (inStreamFence) fenceThought = "";
1890
+ continue;
1891
+ }
1892
+ // Inside a ~~~ fence = raw model Thought/code stream → suppressed from scrollback. Do
1893
+ // NOT bump lastContentAt: while the (now hidden) tokens flow, the blinking bullet keeps
1894
+ // ticking so the user sees the step is still being worked on. But HARVEST the first
1895
+ // Thought clause for the blinking line (task #64) — up to the <code> block, which is
1896
+ // where the prose ends and the tool call begins (we never surface code here).
1897
+ if (inStreamFence) {
1898
+ // Accumulate a bounded head of the stream (enough to see the Thought prose OR the
1899
+ // tool call), capped so a 16k-char runaway can't grow the buffer. normalizeThought
1900
+ // yields a clean label — never raw code/CSS.
1901
+ if (fenceThought.length < 400) {
1902
+ fenceThought += line + "\n";
1903
+ const th = normalizeThought(fenceThought);
1904
+ if (th) liveThought = th;
1905
+ fullLiveThought = thoughtFull(fenceThought); // #96: keep the fuller thought for expand
1906
+ }
1907
+ continue;
1908
+ }
1909
+ if (isToolStepSummary(line)) { lastContentAt = Date.now(); continue; } // swallow
1910
+ if (isRoutingLine(line)) {
1911
+ // Swallow the router's play-by-play entirely — the blinking-bullet ticker (which
1912
+ // already shows the playful word + seconds) is the single in-progress indicator,
1913
+ // so a separate static "Booting the big brain…" line here would just duplicate it.
1914
+ if (line.trim() === "→ Chat reply") plainReplyFlow = true;
1915
+ continue;
1916
+ }
1917
+ // Edit card (a file change made by the agent's edit tools) — rendered as a
1918
+ // colored diff instead of dim prose. Checked before the <code> handling: the
1919
+ // card is a step event, never part of the model's raw code stream, and the ⏺
1920
+ // header is distinctive enough to never collide with generated Python.
1921
+ if (isEditCardHeader(line)) {
1922
+ onContent();
1923
+ inEditCard = true;
1924
+ process.stdout.write(green("⏺") + bold(line.slice(1)) + "\n");
1925
+ lastWasBlank = false;
1926
+ lastContentAt = Date.now();
1927
+ continue;
1928
+ }
1929
+ if (inEditCard) {
1930
+ if (isEditCardBody(line)) {
1931
+ onContent();
1932
+ const m = EDIT_DIFF_RE.exec(line);
1933
+ if (m) {
1934
+ // " 19 - old text" → dim line number, red/green signed content.
1935
+ const paintDiff = m[3] === "+" ? green : red;
1936
+ process.stdout.write(`${m[1]}${dim(m[2])} ${paintDiff(`${m[3]} ${m[4]}`)}\n`);
1937
+ } else {
1938
+ process.stdout.write(dim(line) + "\n"); // "└ summary" / "… (+N more)"
1939
+ }
1940
+ lastWasBlank = false;
1941
+ lastContentAt = Date.now();
1942
+ continue;
1943
+ }
1944
+ inEditCard = false; // card ended — fall through to normal handling
1945
+ }
1946
+ {
1947
+ const t = line.trim();
1948
+ const openM = !inCode || t.startsWith("<code") ? CODE_OPEN_RE.exec(t) : null;
1949
+ if (openM) {
1950
+ // Enter code mode (or stay in it on a duplicate tag). Anything glued after
1951
+ // the tag on the same line is code — print it, minus a glued close tag.
1952
+ // (Reached only for a stray <code> OUTSIDE a ~~~ fence; in-fence code is
1953
+ // suppressed above. Kept as a safety net.)
1954
+ inCode = true;
1955
+ let rest = t.slice(openM[0].length).trim();
1956
+ if (CODE_CLOSE_RE.test(rest)) {
1957
+ inCode = false;
1958
+ rest = rest.replace(CODE_CLOSE_RE, "").trim();
1959
+ }
1960
+ if (rest) {
1961
+ onContent();
1962
+ process.stdout.write(codeColor(rest) + "\n");
1963
+ lastWasBlank = false;
1964
+ }
1965
+ lastContentAt = Date.now();
1966
+ continue;
1967
+ }
1968
+ if (!inCode && isBareCloseTag(t)) {
1969
+ // Stray close tag with no open in sight — swallow, never show it.
1970
+ lastContentAt = Date.now();
1971
+ continue;
1972
+ }
1973
+ }
1974
+ if (inCode) {
1975
+ const t = line.trim();
1976
+ if (CODE_CLOSE_RE.test(t)) {
1977
+ // Close tag, possibly with the code's last line glued in front of it.
1978
+ const before = t.replace(CODE_CLOSE_RE, "").trim();
1979
+ if (before) {
1980
+ onContent();
1981
+ process.stdout.write(codeColor(before) + "\n");
1982
+ lastWasBlank = false;
1983
+ }
1984
+ inCode = false;
1985
+ lastContentAt = Date.now();
1986
+ continue;
1987
+ }
1988
+ if (t === "") { lastContentAt = Date.now(); continue; } // skip blank lines in code
1989
+ onContent();
1990
+ process.stdout.write(codeColor(line) + "\n");
1991
+ lastWasBlank = false;
1992
+ continue;
1993
+ }
1994
+ if (line.trim() === "") {
1995
+ // Vertical rhythm (task #115). Blank lines arrive from the worker unconditionally —
1996
+ // every {"type":"step"} chunk is `text + "\n\n"`, and closeStreamFence() adds its
1997
+ // own — but whether one was PRINTED used to depend on `!thinking && !statusShown`,
1998
+ // i.e. on whether the status ticker happened to have redrawn its two-line block in
1999
+ // the last ~120ms. Identical output rendered two different ways (reported live: the
2000
+ // first ● bullet flush against the prompt, the next three each with a gap above).
2001
+ // Spacing must not be a race, so neither term is consulted any more:
2002
+ // • ACTION stream → swallow every stream-borne blank. Bullets and edit cards form
2003
+ // ONE tight list (task #41); the blank line before the final answer is written
2004
+ // by the CALLER (`console.log(shown ? "" : "\n\n" + answer)`), not here.
2005
+ // • PLAIN-REPLY answer → this text IS the answer, so its blanks are real
2006
+ // paragraph breaks. Keep them (collapsing runs) and clear the status block
2007
+ // first, which is what the old `!statusShown` guard was really protecting.
2008
+ if (plainReplyFlow && !lastWasBlank) {
2009
+ onContent();
2010
+ process.stdout.write("\n");
2011
+ lastWasBlank = true;
2012
+ }
2013
+ continue;
2014
+ }
2015
+ // Agent flow: a "Running → <cmd>" is the START of a command — show it as the live
2016
+ // in-progress status (the blinking bullet names it) rather than a solid bullet; its
2017
+ // completion ("Command passed/failed → …" / "Server up → …") prints the done bullet.
2018
+ // So the pair collapses to: "◑ Running <cmd>…" while it runs, then "● <result>".
2019
+ if (!plainReplyFlow) {
2020
+ const mRun = /^Running → (.+)$/.exec(line.trim());
2021
+ if (mRun) { runningCmd = mRun[1]; thinking = false; lastContentAt = Date.now(); continue; }
2022
+ }
2023
+ if (alreadyFlushed === 0) onContent();
2024
+ if (plainReplyFlow) {
2025
+ // Plain-reply content IS the answer — print it in the default color and remember
2026
+ // it's visible so the caller doesn't reprint it once the turn completes.
2027
+ process.stdout.write(line.slice(alreadyFlushed) + "\n");
2028
+ liveAnswer += line.slice(alreadyFlushed) + "\n";
2029
+ answerShownLive = true;
2030
+ } else {
2031
+ // The remaining out-of-fence ACTION events ("Command passed → …", "Searching code
2032
+ // → …", "Server up → …", "Composing answer…") each print as ONE solid bullet —
2033
+ // the clean, summarized action list (task #41).
2034
+ process.stdout.write(white(BULLET) + " " + line.trim() + "\n");
2035
+ }
2036
+ lastWasBlank = false;
2037
+ }
2038
+ // Plain-reply answers often have NO internal newline at all (a short one-sentence
2039
+ // reply) and can take many seconds to generate — without this, nothing would print
2040
+ // until the whole thing finally lands, right back to the "wait then dump" problem
2041
+ // this was meant to fix. Only safe in the plain-reply flow (see plainReplyFlow above).
2042
+ if (plainReplyFlow && !inCode && carry.length > carryFlushedLen) {
2043
+ if (carryFlushedLen === 0) onContent();
2044
+ process.stdout.write(carry.slice(carryFlushedLen)); // default color — this IS the answer
2045
+ liveAnswer += carry.slice(carryFlushedLen);
2046
+ carryFlushedLen = carry.length;
2047
+ answerShownLive = true;
2048
+ lastWasBlank = false;
2049
+ }
2050
+ // While in the hidden fence, the model's FIRST Thought line often sits in `carry` with
2051
+ // no newline yet (a long reasoning sentence streams token-by-token) — recompute the
2052
+ // live clause from the partial too, so the blinking line updates mid-sentence instead
2053
+ // of only once the line finally breaks (task #64).
2054
+ if (inStreamFence && fenceThought.length < 400) {
2055
+ const th = normalizeThought(fenceThought + carry);
2056
+ if (th) liveThought = th;
2057
+ fullLiveThought = thoughtFull(fenceThought + carry); // #96: fuller thought for expand
2058
+ }
2059
+ // A non-empty remainder is a partial CONTENT line (heartbeats always arrive whole with
2060
+ // a newline), so tokens are actively flowing — keep the ticker asleep.
2061
+ if (carry.trim() !== "") lastContentAt = Date.now();
2062
+ };
2063
+
2064
+ try {
2065
+ for (;;) {
2066
+ if (followAborted) {
2067
+ clearStatus();
2068
+ process.stdout.write(
2069
+ dim("\n(stopped following — the job keeps running on the worker)\n")
2070
+ );
2071
+ return { text: "", shown: false };
2072
+ }
2073
+ let job;
2074
+ try {
2075
+ const jr = await fetch(`${apiBase}/api/worker/jobs/${jobId}`, {
2076
+ headers: { "X-Worker-Key": workerKey },
2077
+ });
2078
+ const parsed = await jr.json().catch(() => ({}));
2079
+ if (jr.ok) {
2080
+ job = parsed;
2081
+ pollFailures = 0; // a clean poll resets the tolerance window
2082
+ } else if (jr.status >= 400 && jr.status < 500) {
2083
+ // Client error (bad/expired key, job genuinely gone) — NOT transient, fail fast.
2084
+ throw new Error(parsed?.error || `job poll failed (HTTP ${jr.status})`);
2085
+ } else {
2086
+ // 5xx — a momentary server/DB blip (e.g. "Worker key lookup failed."). Transient.
2087
+ throw Object.assign(
2088
+ new Error(parsed?.error || `job poll failed (HTTP ${jr.status})`),
2089
+ { transient: true }
2090
+ );
2091
+ }
2092
+ } catch (err) {
2093
+ // A fetch/network exception has no HTTP status — treat it as transient too.
2094
+ const transient = err?.transient === true || isNetworkError(err);
2095
+ if (!transient) throw err;
2096
+ pollFailures += 1;
2097
+ if (pollFailures >= MAX_POLL_FAILURES) {
2098
+ clearStatus();
2099
+ throw new Error(
2100
+ `lost contact with the API after ${pollFailures} tries (${String(err.message).slice(0, 80)}). ` +
2101
+ "The turn may still be running on the worker — try 'continue'."
2102
+ );
2103
+ }
2104
+ await sleep(Math.min(700 * pollFailures, 3000)); // linear backoff, capped at 3s
2105
+ continue;
2106
+ }
2107
+ jobRunning = job.jobStatus === "running";
2108
+ // Live agent-code-model token total (task #83) — monotonic, shown on the thinking line.
2109
+ if (Number.isFinite(job.agentCodeTokens) && job.agentCodeTokens > latestCodeTokens) {
2110
+ latestCodeTokens = job.agentCodeTokens;
2111
+ }
2112
+ // Peak single-request tokens (task #84): keep the per-workspace max across turns.
2113
+ if (Number.isFinite(job.agentMaxCodeTokens) && job.agentMaxCodeTokens > sessionMaxCodeTokens) {
2114
+ sessionMaxCodeTokens = job.agentMaxCodeTokens;
2115
+ }
2116
+ // This turn's OUTPUT-token total (task #104), monotonic across polls.
2117
+ if (Number.isFinite(job.agentOutputTokens) && job.agentOutputTokens > latestOutputTokens) {
2118
+ latestOutputTokens = job.agentOutputTokens;
2119
+ }
2120
+ const text = typeof job.resultText === "string" ? job.resultText : "";
2121
+ if (job.jobStatus === "completed") {
2122
+ // Task #104: fold this turn's output-token total into the lifetime per-workspace +
2123
+ // global (user + coding-URL) counters, exactly once. Best-effort; the API returns
2124
+ // the new global total, which drives the ↓ header indicator.
2125
+ if (latestOutputTokens > 0 && !outputTokensPosted) {
2126
+ outputTokensPosted = true;
2127
+ void addOutputTokens(resolve(process.cwd()), latestOutputTokens).then((t) => {
2128
+ { const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
2129
+ // This turn's total is now folded into globalOutputTokens — zero the live
2130
+ // per-turn tally so the ↓ (global + latest) doesn't double-count it.
2131
+ latestOutputTokens = 0;
2132
+ });
2133
+ }
2134
+ // The job can flip to "completed" between polls with authoritative resultText that
2135
+ // the live stream hasn't caught up to yet (a debounced last chunk, or the final
2136
+ // PATCH landing before its chunk flush was polled). resultText is the source of
2137
+ // truth, so flush any not-yet-shown suffix BEFORE returning — otherwise a plain
2138
+ // reply, whose live stream is its ONLY output, renders truncated mid-word. Gated
2139
+ // to plainReplyFlow: the agent path reprints its answer from resultText via the
2140
+ // caller (shown=false), so a live flush there is unnecessary (and would compound
2141
+ // the separate agent double-print). This runs for the completed poll only; the
2142
+ // running-branch consume below handles every earlier poll.
2143
+ //
2144
+ // The suffix math is only valid when the completed resultText EXTENDS the
2145
+ // streamed accumulation. An older worker REPLACES it with the bare deduped
2146
+ // answer — a shorter, different string — so slicing by shownChars either shows
2147
+ // nothing (the reported "Hello! How" cut) or a garbage tail. Verify with the
2148
+ // seenRaw fingerprint; on a mismatch, recover by answer-prefix instead: print
2149
+ // whatever of the final answer wasn't already shown live.
2150
+ const extendsStream = text.length >= shownChars && text.startsWith(seenRaw);
2151
+ if (plainReplyFlow && extendsStream && text.length > shownChars) {
2152
+ consume(text.slice(shownChars));
2153
+ shownChars = text.length;
2154
+ } else if (plainReplyFlow && !extendsStream && answerShownLive) {
2155
+ const ans = answerFrom(text);
2156
+ let rest = ans.startsWith(liveAnswer) ? ans.slice(liveAnswer.length) : null;
2157
+ if (rest === null) {
2158
+ // The live text may carry a trailing newline the trimmed answer lacks.
2159
+ const printed = liveAnswer.replace(/\s+$/, "");
2160
+ rest = ans.startsWith(printed) ? ans.slice(printed.length) : null;
2161
+ }
2162
+ if (rest) {
2163
+ onContent();
2164
+ process.stdout.write(rest);
2165
+ lastWasBlank = false;
2166
+ }
2167
+ }
2168
+ clearStatus();
2169
+ // Task #104: expose this turn's code-model INPUT + the CUMULATIVE output-token
2170
+ // total (all turns for this user + coding server) so the caller can print a
2171
+ // persistent footer. Output is the running total (globalOutputTokens already holds
2172
+ // every prior turn; latestOutputTokens is THIS turn, not yet folded in because the
2173
+ // addOutputTokens POST above is async) — the same figure the live "↓" showed during
2174
+ // the turn, so it doesn't drop back to just this turn's count at the prompt (#112).
2175
+ // `turnOutputTokens` is kept separately so the caller's footer guard reflects whether
2176
+ // THIS turn used the code model (a plain-reply greeting = 0 → no footer).
2177
+ return {
2178
+ text: answerFrom(text),
2179
+ shown: answerShownLive,
2180
+ inputTokens: latestCodeTokens,
2181
+ outputTokens: globalOutputTokens + latestOutputTokens,
2182
+ turnOutputTokens: latestOutputTokens,
2183
+ };
2184
+ }
2185
+ if (text.length > shownChars) {
2186
+ if (seenRaw.length < 4096) {
2187
+ seenRaw = (seenRaw + text.slice(shownChars)).slice(0, 4096);
2188
+ }
2189
+ consume(text.slice(shownChars));
2190
+ shownChars = text.length;
2191
+ }
2192
+ // Interactive command-approval gate: the agent paused on a risky command. Show the
2193
+ // Yes/No picker once per request id, POST the choice, then let the agent resume.
2194
+ if (
2195
+ job.pendingApproval &&
2196
+ job.pendingApproval.id &&
2197
+ job.pendingApproval.id !== lastApprovalId
2198
+ ) {
2199
+ lastApprovalId = job.pendingApproval.id;
2200
+ clearStatus();
2201
+ // Race the picker against the job ending server-side (#110): if python gives up
2202
+ // (its own approval deadline) and completes the job while we're waiting, dismiss
2203
+ // the picker and DON'T post a now-useless choice — fall through to the normal
2204
+ // completed/failed/cancelled handling below on the next poll.
2205
+ const { ended, allow, dontAsk } = await approvalPromptRacingJob(
2206
+ job.pendingApproval.command || ""
2207
+ );
2208
+ if (ended) {
2209
+ process.stdout.write(
2210
+ dim("\n (the run already ended before you chose — reply 'continue' to resume)\n")
2211
+ );
2212
+ continue; // re-poll; the terminal-status branches below take it from here
2213
+ }
2214
+ // "Yes, don't ask again this session" (max-step prompt only): remember it so every
2215
+ // later turn sends alwaysExtendOnMaxStep and the agent auto-extends silently (#80).
2216
+ if (dontAsk) {
2217
+ alwaysExtendOnMaxStep = true;
2218
+ process.stdout.write(dim(" (won't ask again about the step budget this session)\n"));
2219
+ }
2220
+ try {
2221
+ await fetch(`${apiBase}/api/worker/jobs/${jobId}/approval`, {
2222
+ method: "POST",
2223
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
2224
+ body: JSON.stringify({ id: lastApprovalId, allow }),
2225
+ });
2226
+ } catch {
2227
+ // Best-effort — if the POST fails, the python side times out and treats as deny.
2228
+ }
2229
+ }
2230
+ if (job.jobStatus === "cancelled") {
2231
+ // User-requested stop (Ctrl+C) — a clean outcome, not an error. The worker has
2232
+ // killed the Python agent. Print a quiet note and return an empty, non-persisted
2233
+ // turn (main() drops it from history).
2234
+ clearStatus();
2235
+ process.stdout.write(dim("\n(cancelled — the agent was stopped)\n"));
2236
+ return { text: "", shown: false, cancelled: true };
2237
+ }
2238
+ if (job.jobStatus === "failed") {
2239
+ clearStatus();
2240
+ throw new Error(job.errorMessage || "job failed");
2241
+ }
2242
+ if (
2243
+ job.jobStatus === "pending" &&
2244
+ !warnedPending &&
2245
+ Date.now() - startedAt > 6000
2246
+ ) {
2247
+ warnedPending = true;
2248
+ clearStatus();
2249
+ process.stdout.write(
2250
+ red("\n⚠ no worker has claimed the job — is `gonext-cli` running?\n")
2251
+ );
2252
+ }
2253
+ await sleep(700);
2254
+ }
2255
+ } finally {
2256
+ clearInterval(ticker);
2257
+ clearStatus();
2258
+ clearActiveStatus = null; // no turn owns the status wiper between turns
2259
+ // Task #96: stop mouse-reporting the moment the turn ends (also on cancel/error — this
2260
+ // finally always runs), and detach the click parser, so the user's shell selection is
2261
+ // never captured between turns.
2262
+ disableMouse();
2263
+ if (process.stdin.isTTY) process.stdin.removeListener("data", onMouseData);
2264
+ following = false;
2265
+ currentJobId = null;
2266
+ // Drop anything typed (but not submitted) during the turn — echo was muted, so it's
2267
+ // invisible; without this it would surface on the next prompt.
2268
+ rl.line = "";
2269
+ rl.cursor = 0;
2270
+ }
2271
+ }
2272
+
2273
+ // ---------- REPL ----------
2274
+ async function main() {
2275
+ console.log(cyan("GoNext agent REPL") + dim(` v${REPL_VERSION} · API ${apiBase} · key ${workerKey.slice(0, 8)}…`));
2276
+ // Task #127, Phase 2: the REPL enqueues jobs that the polling DAEMON executes, so ensure
2277
+ // one is running — no GoTerminal needed. Idempotent + BC4-safe: startDaemon() no-ops when
2278
+ // a daemon (ours, GoTerminal's, or a manual one) is already alive. Best-effort; a failure
2279
+ // here shouldn't block the REPL (the user can `gonext-cli start` manually).
2280
+ try {
2281
+ const ctl = await import("./daemon-control.mjs");
2282
+ const r = await ctl.startDaemon();
2283
+ if (r.started) {
2284
+ console.log(dim(` started background worker (pid ${r.pid}) · logs ${ctl.LOG_FILE}`));
2285
+ }
2286
+ } catch {
2287
+ /* non-fatal — jobs just won't run until a daemon is started */
2288
+ }
2289
+ await ensureWorkspace();
2290
+ // Restore this folder's remembered auto-test + deploy-target so the banner + agent-ask
2291
+ // reflect them (both survive a fresh terminal in the same project).
2292
+ sessionTestAuto = await loadSessionTestAuto(resolve(process.cwd()));
2293
+ selectedServer = await loadSessionServer(resolve(process.cwd()));
2294
+ sessionMaxCodeTokens = await loadSessionMaxCodeTokens(resolve(process.cwd()));
2295
+ sessionRagMode = await loadSessionRagMode(resolve(process.cwd())); // task #97: local/cloud/null
2296
+ // Task #104: seed the ↓ global OUTPUT-token total (user + coding-URL) from the API.
2297
+ {
2298
+ const t = await fetchOutputTokenTotals(resolve(process.cwd()));
2299
+ { const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
2300
+ }
2301
+ // Show which models will answer, straight from user settings (also validates auth).
2302
+ try {
2303
+ const probe = await fetchAgentPayload([{ role: "user", content: "ping" }]);
2304
+ const p = probe?.payload ?? {};
2305
+ // Remember the default coder id for the token footer's global-total label, and the
2306
+ // default backend's coding URL so the GLOBAL output-token total is keyed to it (task
2307
+ // #125) when no /model override is active.
2308
+ if (p.codingModelId) defaultCodingModel = p.codingModelId;
2309
+ if (p.codingBaseURL) defaultCodingUrl = p.codingBaseURL;
2310
+ // RAG storage choice (task #97): ask ONCE per folder, and only when RAG is actually
2311
+ // enabled in Settings. Default Yes = LOCAL (kept on this machine, no S3 needed).
2312
+ if (p.ragEnabled && sessionRagMode === null) {
2313
+ const local = await askYesNo(
2314
+ "Store this folder's RAG knowledge base LOCALLY on this machine?\n" +
2315
+ "(Yes = local, kept in ~/.gonext per folder — no S3 needed; No = cloud, your S3 bucket)",
2316
+ true /* default Yes = local */
2317
+ );
2318
+ sessionRagMode = local ? "local" : "cloud";
2319
+ await saveSessionRagMode(resolve(process.cwd()), sessionRagMode);
2320
+ }
2321
+ console.log(
2322
+ dim(
2323
+ `agent model: ${p.agentModelId || probe?.modelKey || "?"}` +
2324
+ (p.codingModelId ? ` · coder: ${p.codingModelId}` : "") +
2325
+ (p.ragEnabled ? ` · RAG ${sessionRagMode === "cloud" ? "cloud" : "local"}` : "") +
2326
+ ` · auto-test: ${sessionTestAuto ? "on" : "off"}` +
2327
+ (selectedServer ? ` · deploy: ${selectedServer.name} (${selectedServer.user}@${selectedServer.host})` : "")
2328
+ ) + (sessionTestAuto ? "" : dim(" (/test-auto to enable)"))
2329
+ );
2330
+ // Task #127, Phase 3: if the base chat model server isn't answering, offer to set it up
2331
+ // (download + start mlx_lm.server). BC3: only runs when NOTHING answers — an already-up
2332
+ // model is left untouched, and this is skipped entirely. Interactive TTYs only.
2333
+ if (process.stdin.isTTY && process.stdout.isTTY) {
2334
+ try {
2335
+ const md = await import("./model-doctor.mjs");
2336
+ const chatUrl = String(p.agentBaseURL || "").trim() || `http://127.0.0.1:${md.DEFAULT_CHAT_PORT}`;
2337
+ if (!(await md.probeModelServer(chatUrl))) {
2338
+ console.log(
2339
+ dim(`\ngonext: the chat model server (${md.modelRoot(chatUrl)}) isn't responding.`)
2340
+ );
2341
+ const go = await askYesNo("Set up the base model now (download if needed + start)?", true);
2342
+ if (go) {
2343
+ await md.runDoctor({
2344
+ apiBase,
2345
+ workerKey,
2346
+ log: (s) => console.log(s),
2347
+ confirm: (q, defYes) => askYesNo(q, defYes),
2348
+ });
2349
+ } else {
2350
+ console.log(dim(" Skipped. Run `gonext-cli doctor` anytime to set it up.\n"));
2351
+ }
2352
+ }
2353
+ } catch {
2354
+ /* doctor is best-effort — never block the REPL from starting */
2355
+ }
2356
+ // Task #127: the default coding model is a cloud coder (Kimi K3) with no API key yet.
2357
+ // Ask for it (masked). If skipped, the account stays in this "waiting" state
2358
+ // (server-side: coder configured, no key) and we ASK AGAIN on every startup until it's
2359
+ // set. Until then the agent falls back to the chat model for coding (see the payload's
2360
+ // codingKeyMissing handling), so the terminal still works. Logic lives in a module so
2361
+ // it's unit-testable with a mock ask/save.
2362
+ const { promptCodingKeyIfMissing } = await import("./coding-key-prompt.mjs");
2363
+ await promptCodingKeyIfMissing(p, {
2364
+ askSecret,
2365
+ saveCodingKey,
2366
+ log: (s) => console.log(s),
2367
+ colors: { dim, cyan, green, red },
2368
+ });
2369
+ }
2370
+ } catch (err) {
2371
+ // Task #127 (zero-config): a freshly-paired account is seeded with a default local
2372
+ // agent model, so this "no model" path is normally unreachable. An account paired
2373
+ // BEFORE that change has none — point them at re-login, which now seeds it, rather than
2374
+ // only the raw web-Settings instruction.
2375
+ if (/no agent model configured/i.test(err?.message ?? "")) {
2376
+ console.error(
2377
+ red("gonext: no agent model is configured for your account yet.") +
2378
+ dim(
2379
+ "\n Run `gonext-cli login` to re-connect — new logins set up a local" +
2380
+ "\n default automatically. Or set the Agent model URL in the web app → Settings → Agent."
2381
+ )
2382
+ );
2383
+ } else {
2384
+ console.error(red(`gonext: ${err.message}`));
2385
+ }
2386
+ process.exit(1);
2387
+ }
2388
+ console.log(dim("Ask about this repo. Type / to see commands (Tab completes). Ctrl-C aborts a running turn.\n"));
2389
+
2390
+ const cwd = resolve(process.cwd());
2391
+ const history = await loadSession(cwd);
2392
+ if (history.length > 0) {
2393
+ const turns = history.filter((m) => m.role === "user").length;
2394
+ console.log(
2395
+ dim(`Resumed session: ${turns} prior turn(s) from last time in this folder — /reset to start fresh.\n`)
2396
+ );
2397
+ }
2398
+ // Ctrl-C arrives on ONE of two paths depending on the readline mode: with
2399
+ // terminal:true (real TTY) readline's raw-mode byte scanning intercepts \x03 and
2400
+ // emits rl "SIGINT" (no OS signal is delivered); with terminal:false (piped input)
2401
+ // the OS delivers a real SIGINT to the process. Attach the same handler to both —
2402
+ // only one can ever fire for a given mode, so there's no double-handling.
2403
+ const onInterrupt = () => {
2404
+ // Ctrl+C while a picker is up = cancel it.
2405
+ if (listPickerActive) finishList(-1);
2406
+ // Ctrl+C while the approval picker is up = decline it (and fall through to also
2407
+ // cancel the turn) — otherwise the poll loop stays blocked awaiting a choice.
2408
+ if (approvalActive) finishApproval(false);
2409
+ if (following) {
2410
+ if (!cancelRequested && currentJobId) {
2411
+ // First Ctrl+C: actually CANCEL the running turn server-side (stop the model),
2412
+ // not just detach. The worker sees the "cancelled" status and kills the Python
2413
+ // agent; the poll loop then returns cleanly with the "(cancelled)" note.
2414
+ cancelRequested = true;
2415
+ const jobId = currentJobId;
2416
+ process.stdout.write(red("\n^C ") + dim("stopping the agent…\n"));
2417
+ fetch(`${apiBase}/api/worker/jobs/cancel`, {
2418
+ method: "POST",
2419
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
2420
+ body: JSON.stringify({ jobId }),
2421
+ }).catch(() => {
2422
+ /* best-effort — the worker also self-detects via the chunk 409 path */
2423
+ });
2424
+ } else {
2425
+ // Second Ctrl+C (or no jobId yet): give up waiting locally. The worker keeps
2426
+ // trying to stop, but we stop following.
2427
+ followAborted = true;
2428
+ process.stdout.write(red("\n^C "));
2429
+ }
2430
+ } else {
2431
+ // Ctrl+C at a prompt with the slash overlay open = dismiss the overlay first (#109).
2432
+ if (slashOverlayActive) closeSlashOverlay();
2433
+ process.stdout.write(dim("\n(/exit to quit)\n"));
2434
+ // Discard anything half-typed (Ctrl-C at a prompt means "never mind") and
2435
+ // redraw through readline so its cursor tracking stays right.
2436
+ rl.line = "";
2437
+ rl.cursor = 0;
2438
+ rl.prompt();
2439
+ }
2440
+ };
2441
+ process.on("SIGINT", onInterrupt);
2442
+ rl.on("SIGINT", onInterrupt);
2443
+
2444
+ // The task from the most recent Ctrl+C-cancelled turn — so a bare "continue" right after
2445
+ // resumes it. A cancelled turn is popped from history (nothing to continue otherwise), so
2446
+ // without this "continue" reaches the agent with no task and it replies as generic chat.
2447
+ let lastCancelledTask = "";
2448
+
2449
+ for (;;) {
2450
+ rl.prompt();
2451
+ // Absorb blank Enter-presses SILENTLY under the same prompt — without this, lines
2452
+ // typed while the agent was busy on a long turn (queued, not yet read) all drain at
2453
+ // once the moment the turn ends, each re-printing ">> " with no newline between them
2454
+ // (e.g. ">> >> >> "). Only re-print the prompt for a genuine stdin close or input.
2455
+ let lineRaw;
2456
+ for (;;) {
2457
+ lineRaw = await nextLine();
2458
+ if (lineRaw === null || lineRaw.trim()) break;
2459
+ }
2460
+ if (lineRaw === null) break; // stdin closed
2461
+ const line = lineRaw.trim();
2462
+ if (line === "/exit" || line === "/quit") break;
2463
+ if (line === "/" || line === "/help") {
2464
+ printCommands();
2465
+ continue;
2466
+ }
2467
+ if (line === "/model") {
2468
+ await chooseModel();
2469
+ continue;
2470
+ }
2471
+ if (line === "/server") {
2472
+ await chooseServer();
2473
+ await saveSession(cwd, history); // remember the pick for this folder
2474
+ continue;
2475
+ }
2476
+ if (line === "/test-auto") {
2477
+ sessionTestAuto = !sessionTestAuto;
2478
+ await saveSession(cwd, history); // persist the choice for this folder
2479
+ console.log(
2480
+ (sessionTestAuto ? green("auto-test: ON") : dim("auto-test: OFF")) +
2481
+ dim(
2482
+ sessionTestAuto
2483
+ ? " — the agent will verify its work and fix until it passes, for this folder.\n"
2484
+ : " for this folder.\n"
2485
+ )
2486
+ );
2487
+ continue;
2488
+ }
2489
+ if (line === "/rag-local") {
2490
+ // Toggle this folder's RAG store between local disk and the cloud (S3). task #97.
2491
+ sessionRagMode = sessionRagMode === "local" ? "cloud" : "local";
2492
+ await saveSession(cwd, history); // persist the choice for this folder
2493
+ console.log(
2494
+ (sessionRagMode === "local" ? green("RAG: LOCAL") : dim("RAG: cloud")) +
2495
+ dim(
2496
+ sessionRagMode === "local"
2497
+ ? " — this folder's knowledge base is stored on this machine (~/.gonext), no S3 needed.\n"
2498
+ : " — this folder's knowledge base is stored in your S3 bucket.\n"
2499
+ )
2500
+ );
2501
+ continue;
2502
+ }
2503
+ if (line === "/reset" || line === "/new") {
2504
+ history.length = 0;
2505
+ await clearSession(cwd);
2506
+ console.log(dim("Session cleared — starting fresh.\n"));
2507
+ continue;
2508
+ }
2509
+ if (line === "/output-token") {
2510
+ // Task #104: print the OUTPUT-token total for THIS workspace (and the global one).
2511
+ const t = await fetchOutputTokenTotals(cwd);
2512
+ { const g = pickGlobalTotal(t); if (g !== null) globalOutputTokens = g; }
2513
+ const ws = t && Number.isFinite(t.workspaceTotal) ? t.workspaceTotal : 0;
2514
+ // Task #126: same saved-else-counter rule as the footer (pickGlobalTotal), not the
2515
+ // raw counter — so /output-token and the live ↓ footer agree.
2516
+ const gl = pickGlobalTotal(t) ?? globalOutputTokens;
2517
+ console.log(
2518
+ cyan(`↓ output tokens`) +
2519
+ dim(` — this workspace: `) +
2520
+ `${fmtTokens(ws)}` +
2521
+ dim(` · global (you + coding server): `) +
2522
+ `${fmtTokens(gl)}\n`
2523
+ );
2524
+ continue;
2525
+ }
2526
+ if (line === "/reset-output-token-global") {
2527
+ // Task #104: zero the GLOBAL (user + coding-URL) output-token counter AND clear all
2528
+ // per-workspace counters, after confirm.
2529
+ const ok = await askYesNo(
2530
+ `Reset the GLOBAL output-token total (currently ${fmtTokens(globalOutputTokens)}) to 0? This also clears every workspace's output-token total.`,
2531
+ false
2532
+ );
2533
+ if (!ok) {
2534
+ console.log(dim("Cancelled — output-token totals unchanged.\n"));
2535
+ continue;
2536
+ }
2537
+ const done = await resetGlobalOutputTokens();
2538
+ if (done) {
2539
+ globalOutputTokens = 0;
2540
+ console.log(green("✓ global + workspace output-token totals reset to 0.\n"));
2541
+ } else {
2542
+ console.log(dim("Could not reset the output-token totals (API error).\n"));
2543
+ }
2544
+ continue;
2545
+ }
2546
+ if (line.startsWith("/revert")) {
2547
+ const runId = line.split(/\s+/)[1] ?? "";
2548
+ await new Promise((res) => {
2549
+ const p = spawn(
2550
+ process.execPath,
2551
+ [join(DIR, "gonext-cli.mjs"), "workspace", "revert", ...(runId ? [runId] : [])],
2552
+ { stdio: "inherit" }
2553
+ );
2554
+ p.on("exit", res);
2555
+ p.on("error", res);
2556
+ });
2557
+ continue;
2558
+ }
2559
+ // Any OTHER "/…" is a typo/unknown command — don't send it to the agent as a
2560
+ // question; point the user at /help (all real commands were handled above).
2561
+ if (line.startsWith("/")) {
2562
+ const cmd = line.split(/\s+/)[0];
2563
+ console.log(dim(`unknown command: ${cmd} — type /help (or / then Tab) to list commands\n`));
2564
+ continue;
2565
+ }
2566
+ // Resume a cancelled task: a BARE "continue" right after a Ctrl+C re-runs the ORIGINAL
2567
+ // request. Only the immediately-next input may resume (consumed here) and only a bare
2568
+ // continue-word — a message with real instructions is a new task. Slash commands above
2569
+ // used `continue` and never reach here, so `/model` then "continue" still resumes.
2570
+ const pendingResume = lastCancelledTask;
2571
+ lastCancelledTask = "";
2572
+ let taskLine = line;
2573
+ if (
2574
+ pendingResume &&
2575
+ /^(continue|resume|go on|keep going|carry on|proceed)\.?$/i.test(line)
2576
+ ) {
2577
+ taskLine = pendingResume;
2578
+ const shown = pendingResume.length > 80 ? pendingResume.slice(0, 80) + "…" : pendingResume;
2579
+ console.log(dim(`↻ resuming the cancelled task: ${shown}\n`));
2580
+ }
2581
+ history.push({ role: "user", content: taskLine });
2582
+ try {
2583
+ const { text: answer, shown, cancelled, inputTokens, outputTokens, turnOutputTokens } =
2584
+ await runAgentTurn(history);
2585
+ if (answer) {
2586
+ history.push({ role: "assistant", content: answer });
2587
+ // Persist after every successful turn (not just on clean exit) — an ungraceful
2588
+ // termination (killed terminal, force-quit) would otherwise lose the whole
2589
+ // session even though most of it completed fine.
2590
+ await saveSession(cwd, history);
2591
+ // A plain-reply answer already streamed live in the loop above — printing it
2592
+ // again here would just duplicate it; a blank line is enough for spacing.
2593
+ console.log(shown ? "" : `\n\n${answer}\n`);
2594
+ // Task #104: persistent token footer at the bottom of the screen — this turn's
2595
+ // code-model INPUT + this turn's OUTPUT (↓ out), then the model's GLOBAL lifetime
2596
+ // output total (all turns for this user + coding server), labelled with the model
2597
+ // name so it's clear which coder that running total belongs to. Guard on THIS turn's
2598
+ // activity so a plain-reply greeting that did no code-model work shows nothing (#112).
2599
+ if ((inputTokens ?? 0) > 0 || (turnOutputTokens ?? 0) > 0) {
2600
+ const coder = sessionCodingLabel || defaultCodingModel;
2601
+ console.log(
2602
+ dim("tokens · ↑ in ") +
2603
+ fmtTokens(inputTokens ?? 0) +
2604
+ dim(" · ") +
2605
+ cyan(`↓ out ${fmtTokens(turnOutputTokens ?? 0)}`) +
2606
+ dim(" · ") +
2607
+ dim(`${coder ? coder + " " : ""}total `) +
2608
+ cyan(`↓ ${fmtTokens(outputTokens ?? 0)}`)
2609
+ );
2610
+ }
2611
+ } else {
2612
+ history.pop(); // aborted/failed/cancelled turn — don't poison the next request
2613
+ // A turn that ended WITHOUT an answer (Ctrl+C cancel, or an abort/failure) must not
2614
+ // silently launch another turn. Any keystrokes the user typed DURING the dead turn
2615
+ // sit in `_lineQueue` — they land there (not ignored) whenever `following` was still
2616
+ // false, e.g. during the agent-ask round-trip at the very start of the turn, or
2617
+ // while the user was mashing "continue"/Enter. If left queued, `nextLine()` drains
2618
+ // them back-to-back on the next loop iterations, each auto-firing a fresh turn that
2619
+ // a straggler Ctrl+C then lands on — the reported "every turn immediately shows
2620
+ // (cancelled)" loop. Drop that buffered input so the NEXT turn only starts when the
2621
+ // user deliberately types something new (bug #92). Resume via "continue" still works:
2622
+ // that's typed AFTER the cancel note, so it arrives through the live waiter, not the
2623
+ // queue.
2624
+ if (_lineQueue.length > 0) _lineQueue.length = 0;
2625
+ // Ctrl+C cancel already printed its own "(cancelled)" note in runAgentTurn — a
2626
+ // second "(no answer)" line here would be redundant/misleading.
2627
+ if (cancelled) {
2628
+ // Remember the ACTUAL task that ran (taskLine, not a bare "continue") so the
2629
+ // next bare "continue" resumes it — even if it was cancelled during step 1,
2630
+ // before any worker-side checkpoint step was written.
2631
+ lastCancelledTask = taskLine;
2632
+ } else {
2633
+ console.log(red("\n(no answer — run aborted or failed)\n"));
2634
+ }
2635
+ }
2636
+ } catch (err) {
2637
+ history.pop();
2638
+ // Same reasoning as the no-answer branch (bug #92): don't let input buffered during a
2639
+ // turn that just threw auto-fire the next one.
2640
+ if (_lineQueue.length > 0) _lineQueue.length = 0;
2641
+ console.error(red(`\ngonext: ${err.message}\n`));
2642
+ }
2643
+ }
2644
+ rl.close();
2645
+ process.exit(0);
2646
+ }
2647
+
2648
+ main();