@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.
- package/README.md +52 -0
- package/coding-key-prompt.mjs +47 -0
- package/daemon-control.mjs +232 -0
- package/device-login.mjs +141 -0
- package/gonext-cli.mjs +3216 -0
- package/gonext-repl.mjs +2648 -0
- package/gonext_agent_chat.py +7576 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/model-doctor.mjs +571 -0
- package/package.json +53 -0
- package/thinking_words.txt +1003 -0
package/gonext-repl.mjs
ADDED
|
@@ -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();
|