acuvo-code 0.5.4 → 0.6.0

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/lib/chat.mjs CHANGED
@@ -1,536 +1,545 @@
1
- /**
2
- * ── ⭐ THE INTERACTIVE SESSION ───────────────────────────────────────────────
3
- *
4
- * Every invocation of `acuvo "task"` started COLD: it re-gathered the workspace,
5
- * rebuilt the prompt, and knew nothing about the last thing you asked. So the
6
- * second instruction cost as much as the first, and "now do the same for the
7
- * other file" was not a sentence you could say.
8
- *
9
- * ⭐ AND THE ECONOMICS ARE THE ARGUMENT, NOT JUST THE ERGONOMICS. Measured
10
- * 2026-08-09: an identical prompt prefix cached at **97.2%**, dropping the call
11
- * cost **4.3x** ($0.000836 → $0.000195). A session that APPENDS keeps that
12
- * prefix intact, so every turn after the first is nearly free. A tool that
13
- * rebuilds its prompt each time throws that away and looks identical from the
14
- * outside — which is exactly why this is worth building rather than assuming.
15
- *
16
- * ── ⚠️ WHY NOT A FULL TUI ────────────────────────────────────────────────────
17
- * `readline` and plain writes, no alternate screen buffer, no cursor addressing.
18
- * A TUI that redraws breaks `>` redirection, breaks piping into a file, breaks
19
- * `tee`, and breaks every terminal that is not the one it was tested in. The
20
- * output here is append-only text, so a session transcript is a file you can
21
- * keep. That is a deliberate trade of polish for portability.
22
- */
23
-
24
- import { createInterface } from 'node:readline';
25
- import { readBoxedLine, pinRegion } from './input-box.mjs';
26
- import { EXIT_INTERRUPTED } from './interrupt.mjs';
27
- import { parseSlash, runSlashCommand } from './slash.mjs';
28
- import { estimateMessagesTokens } from './compact.mjs';
29
-
30
- /** What ends a session. `exit`/`quit` because both are muscle memory. */
31
- const QUIT = new Set(['exit', 'quit', ':q', 'bye']);
32
-
33
- /**
34
- * ⚠️ CONTEXT GROWS UNBOUNDED AND A CODING SESSION IS THE WORST CASE — tool
35
- * results carry whole files. Left alone, turn 30 sends everything from turns
36
- * 1-29 and eventually 400s on a context-length error mid-thought.
37
- *
38
- * The trim keeps the SYSTEM message and the FIRST user message (the workspace
39
- * context the cacheable prefix, dropping it would cost more than it saves) and
40
- * discards the oldest middle turns.
41
- */
42
- export const MAX_HISTORY_MESSAGES = 40;
43
-
44
- /**
45
- * ── 💰⭐⭐⭐ THE TRIM WAS A CONTINUOUS SLIDE, AND IT COST ~90% OF THE CACHE ──
46
- *
47
- * `messages.slice(-keep)` re-slices on EVERY turn once a session passes the
48
- * cap, so the third message of the prompt is different every time. Prefix
49
- * caching matches from the first token and stops at the first difference so
50
- * everything after the 2-message head was re-bought at full price, every turn,
51
- * for the rest of the session.
52
- *
53
- * ⭐ MEASURED (no credits, no network — a simulated 60-turn session counting how
54
- * much of each prompt is byte-identical to the previous one):
55
- *
56
- * CURRENT slice(-38) cache 9.2% history 38-40
57
- * stepped high=48 low=40 cache 74.5% history 38-48
58
- * stepped high=56 low=40 cache 85.3% history 38-56
59
- * stepped high=64 low=40 cache 89.7% history 38-64 <- shipped
60
- * stepped high=72 low=40 cache 92.0% history 38-72
61
- *
62
- * ⚠️ THE HEAD BEING STABLE IS NOT THE SAME AS THE PROMPT BEING CACHED, and
63
- * measuring position 0 alone would have said this code was fine. The 2-message
64
- * head never moved; the cacheable prefix was still 2 messages out of 40.
65
- *
66
- * ⭐ `LOW` IS TODAY'S CAP ON PURPOSE, so this can only ever keep MORE history
67
- * than the rule it replaces never less. That makes it a pure win rather than
68
- * a trade of memory for money, and it is why HIGH was raised instead of LOW
69
- * being lowered (console shipped 16/8, halving its window; the CLI holds whole
70
- * files in tool results and cannot afford to forget more).
71
- *
72
- * ⚠️ RAISING `HIGH` COSTS TOKENS PER REQUEST, and the ceiling that matters is
73
- * not here: `turn.mjs` compacts above CONTEXT_BUDGET_TOKENS (96k), which
74
- * `compact.mjs` warns is the moment the cache discount dies for good. This is a
75
- * MESSAGE-COUNT backstop, not the token bound 64 messages of ordinary turns
76
- * sit far under 96k, but a session whose tool results carry whole files can
77
- * approach it, and then compaction (with its own hysteresis) takes over. 72 and
78
- * 80 buy 2 more points of cache for materially more of that risk, which is why
79
- * 64 is the shipped number and not the best one in the table.
80
- */
81
- export const HISTORY_HIGH_WATER = 64;
82
- export const HISTORY_LOW_WATER = MAX_HISTORY_MESSAGES;
83
-
84
- /**
85
- * ⚠️ WELL UNDER `turn.mjs`'s CONTEXT_BUDGET_TOKENS (96,000), not near it. The
86
- * request also carries the TOOL OFFER measured at ~15,200 tokens for the
87
- * CLI's 63 verbs — plus the system prompt, so history must leave room for both
88
- * and still clear the line where compaction fires. 55k + ~15k offer + prompt
89
- * sits comfortably inside 96k.
90
- *
91
- * ⭐ This is a CEILING, not a target: an ordinary session (~551 tokens/message,
92
- * measured) reaches the 64-message cap at ~35k and never comes near it. It
93
- * exists for the file-heavy session, which is exactly the one that would
94
- * otherwise be pushed into permanent compaction by the higher message cap.
95
- */
96
- export const HISTORY_TOKEN_CEILING = 55_000;
97
-
98
- export function trimHistory(messages, max = HISTORY_HIGH_WATER) {
99
- if (!Array.isArray(messages)) return messages;
100
- /**
101
- * ⚠️⚠️ THE GATE MUST ASK BOTH QUESTIONS, AND MY FIRST VERSION ASKED ONLY ONE.
102
- * It read `messages.length <= max` and returned early so the token ceiling
103
- * below was unreachable for exactly the session it was written for: 64 heavy
104
- * messages are under the COUNT cap and ~137,000 tokens, and the function
105
- * handed them straight back. The test reported 133,491 tokens and I had to
106
- * measure to find that the ceiling was never consulted at all rather than
107
- * being wrong.
108
- */
109
- const overBudget = estimateMessagesTokens(messages) > HISTORY_TOKEN_CEILING;
110
- if (messages.length <= max && !overBudget) return messages;
111
- const head = messages.slice(0, 2); // system + the context-bearing user turn
112
- /**
113
- * ⭐ THE HEAD MOVES IN STEPS, NOT EVERY TURN. `dropped` is a multiple of STEP,
114
- * so it changes only when the conversation crosses the next boundary and
115
- * between boundaries the prompt is byte-identical to the previous turn's.
116
- *
117
- * ⚠️ NOT `length > HIGH ? slice(-LOW)`. That is still a continuous slide past
118
- * the mark and is the exact mistake console's own guard records catching
119
- * before it shipped.
120
- */
121
- /**
122
- * ⚠️⚠️ THE LOW WATER MUST BE DERIVED FROM `max`, NOT PINNED TO A CONSTANT,
123
- * AND I ALMOST SHIPPED IT PINNED. My first version was
124
- * `Math.min(HISTORY_LOW_WATER, max)`. `runChat`'s default `maxHistory` was
125
- * MAX_HISTORY_MESSAGES (40), which equals HISTORY_LOW_WATER — so `step`
126
- * became `max(1, 0)` = 1, and one-message steps ARE the continuous slide this
127
- * whole change exists to remove. The fix would have measured 89.6% in a
128
- * simulation and done NOTHING on the only path that calls it.
129
- *
130
- * ⭐ 0.625 is 40/64 — the shipped ratio, so the default keeps exactly the
131
- * numbers that were measured, and any other `max` still steps properly
132
- * instead of silently degrading to a slide.
133
- */
134
- const low = Math.min(HISTORY_LOW_WATER, Math.floor(max * 0.625));
135
- const step = Math.max(1, max - low);
136
- const body = messages.slice(head.length);
137
- const overflow = Math.max(0, messages.length - max);
138
- let dropped = Math.ceil(overflow / step) * step;
139
- /**
140
- * ── ⚠️⚠️ AND A TOKEN CEILING, BECAUSE MESSAGE COUNT IS THE WRONG UNIT ──────
141
- *
142
- * MEASURED against the one real recorded session on disk (93,036 prompt
143
- * tokens, 18 messages, `.acuvo/sessions/20260814-095636-99d4.json`):
144
- *
145
- * avg message ~551 tokens largest observed ~2,145 tokens
146
- *
147
- * at 40 msgs: typical ~22,000 worst case (all like the largest) ~85,800
148
- * at 64 msgs: typical ~35,300 worst case ~137,300
149
- *
150
- * ⚠️ SO RAISING THE COUNT ALONE MOVES THE WORST CASE FROM JUST UNDER THE
151
- * 96k CONTEXT BUDGET TO WELL OVER IT. Past that line `turn.mjs` compacts, and
152
- * `compact.mjs` is explicit that compaction voids the cache discount and
153
- * "once it starts, it never stops" so a change made ENTIRELY to protect the
154
- * cache would, in a file-heavy session, destroy it. A tool result carrying a
155
- * whole file is not the exception in a coding session; it is the normal case.
156
- *
157
- * ⭐ THE CEILING DROPS IN THE SAME `step` BLOCKS. Any multiple of `step`
158
- * keeps block alignment, so the prefix still changes only at boundaries and
159
- * the caching win is untouched this bounds the worst case without
160
- * reintroducing a slide.
161
- */
162
- const budget = Math.max(1, HISTORY_TOKEN_CEILING - estimateMessagesTokens(head));
163
- while (dropped < body.length && estimateMessagesTokens(body.slice(dropped)) > budget) {
164
- dropped += step;
165
- }
166
- let tail = body.slice(dropped);
167
- /**
168
- * ⚠️ A `tool` MESSAGE WITHOUT ITS `assistant` TOOL CALL IS A HARD 400 from
169
- * every OpenAI-shaped provider "tool_call_id did not have a preceding
170
- * message with tool_calls". Slicing mid-exchange produces exactly that, and it
171
- * would surface as a mysterious API error thirty turns into a session.
172
- */
173
- while (tail.length > 0 && tail[0].role === 'tool') tail = tail.slice(1);
174
- return [...head, ...tail];
175
- }
176
-
177
- /**
178
- * One prompt line. Returns null on EOF (Ctrl-D, or a pipe that ran out).
179
- *
180
- * ⚠️ THE CLOSED CHECK IS NOT DEFENSIVE, IT IS THE PIPED CASE. Found by piping a
181
- * list of prompts in: readline emits 'close' when the stream ends, and the NEXT
182
- * `rl.question()` throws ERR_USE_AFTER_CLOSE. The first turn had already
183
- * succeeded and written a real file, so the session crashed AFTER doing its job
184
- * the worst shape of failure, because the work looks lost.
185
- *
186
- * Scripted input matters beyond tests: piping a prompt list is how anyone would
187
- * automate this.
188
- */
189
- function ask(rl, prompt, state) {
190
- if (state.closed) return Promise.resolve(null);
191
- return new Promise((resolve) => {
192
- let answered = false;
193
- const onClose = () => { if (!answered) resolve(null); };
194
- rl.once('close', onClose);
195
- rl.question(prompt, (line) => {
196
- answered = true;
197
- rl.removeListener('close', onClose);
198
- resolve(line);
199
- });
200
- });
201
- }
202
-
203
- /**
204
- * Run an interactive session.
205
- *
206
- * `runOne(task, priorMessages)` performs one turn and returns the session
207
- * outcome injected rather than imported so this loop is testable with a stub
208
- * and never needs a model or a terminal in a test.
209
- */
210
- /**
211
- * ── ⚠️ PIPED INPUT IS A DIFFERENT PROBLEM AND NEEDED A DIFFERENT ANSWER ──────
212
- * `readline` on a non-TTY DRAINS the stream as fast as it can and emits 'close'
213
- * the moment it ends. The model call for turn 1 takes seconds, by which point
214
- * the interface is already closed and every later prompt is lost measured:
215
- * a three-line pipe ran exactly ONE turn and exited quietly, which is worse than
216
- * crashing because it looks like it worked.
217
- *
218
- * So a pipe is read WHOLE and replayed from a queue. A TTY keeps the real
219
- * readline loop, where a human types the next line after seeing the last answer.
220
- * Two input shapes, two mechanisms pretending they are the same is what broke.
221
- */
222
- async function readAllLines(input) {
223
- const chunks = [];
224
- for await (const chunk of input) chunks.push(chunk);
225
- return Buffer.concat(chunks.map((c) => (typeof c === 'string' ? Buffer.from(c) : c)))
226
- .toString('utf8')
227
- .split(String.fromCharCode(10))
228
- .map((l) => l.replace(String.fromCharCode(13), ''));
229
- }
230
-
231
- /**
232
- * ── ⚠️⚠️⭐ READLINE EATS CTRL-C. MEASURED IN NODE'S OWN SOURCE ──────────────
233
- *
234
- * This is the finding that made an interrupt handler in `bin/acuvo.mjs`
235
- * necessary-but-not-sufficient. Read out of `process.binding('natives')` on
236
- * node v22.17.0, `internal/readline/interface.js`, the ttyWrite ctrl-key
237
- * switch, verbatim:
238
- *
239
- * case 'c':
240
- * if (this.listenerCount('SIGINT') > 0) {
241
- * this.emit('SIGINT');
242
- * } else {
243
- * // This readline instance is finished
244
- * this.close();
245
- * this[kQuestionReject]?.(new AbortError('Aborted with Ctrl+C'));
246
- * }
247
- *
248
- * ⚠️ So with a TTY readline open and NO `'SIGINT'` listener on the interface,
249
- * Ctrl-C never reaches `process.on('SIGINT')` at all readline just closes
250
- * itself. The run in flight would have carried on to completion, for minutes,
251
- * with the user's Ctrl-C having produced nothing on screen. That is strictly
252
- * worse than the bug we set out to fix, and no amount of correct handling in
253
- * `bin/acuvo.mjs` would have been reached.
254
- *
255
- * ⭐ So the interface takes a listener whose whole job is to hand the signal
256
- * back to the process, where the one policy in `interrupt.mjs` decides between
257
- * "stop after this round" and "quit now".
258
- *
259
- * ── ⚠️⚠️ AND A SYNTHETIC EMIT INTO AN EMPTY EMITTER DOES NOTHING ────────────
260
- *
261
- * `process.emit('SIGINT')` is plain `EventEmitter.emit` — it does NOT invoke
262
- * the OS default action. `turn.mjs` installs the process signal handlers inside
263
- * `runSession`, so before the first turn of a session there are ZERO listeners
264
- * and the emit would return `false` having done absolutely nothing. Ctrl-C at
265
- * the very first prompt would be inert.
266
- *
267
- * ⭐ Hence the count check and the explicit exit: **every path out of this
268
- * function either aborts a run or ends the process.** That is the rule this
269
- * whole feature is built on, and it is the one that is easy to break here.
270
- */
271
- export function deliverInterrupt({
272
- emit = (sig) => process.emit(sig),
273
- listenerCount = (sig) => process.listenerCount(sig),
274
- exit = (code) => process.exit(code),
275
- } = {}) {
276
- if (listenerCount('SIGINT') > 0) {
277
- emit('SIGINT');
278
- return 'delegated';
279
- }
280
- exit(EXIT_INTERRUPTED);
281
- return 'exited';
282
- }
283
-
284
- export async function runChat({
285
- runOne,
286
- render,
287
- input = process.stdin,
288
- output = process.stdout,
289
- banner = '',
290
- /**
291
- * ⚠️ THE HIGH WATER MARK, NOT THE OLD FLAT CAP. This default is what makes
292
- * the stepped trim REACH the live session see the note in `trimHistory`
293
- * about the version of this fix that measured 89.6% and changed nothing.
294
- */
295
- maxHistory = HISTORY_HIGH_WATER,
296
- /**
297
- * ⚠️ INJECTED SO A TEST CAN SEE IT. The real one exits the process, and a
298
- * test that could not substitute it could only assert this feature by killing
299
- * its own runner. Default is production behaviour, so no caller changes.
300
- */
301
- onInterrupt = deliverInterrupt,
302
- /**
303
- * ── ⭐ THE `/` SURFACE'S ONE SEAM ─────────────────────────────────────────
304
- *
305
- * Providers for the things a command reports on — skills, MCP servers, spend,
306
- * the model. `bin/` owns where those facts come from; this loop only asks, for
307
- * the same reason `workspace.mjs` takes `claimPath` and `journal` injected
308
- * rather than importing them.
309
- *
310
- * ⚠️ `{}` BY DEFAULT, NOT `null`. Every command degrades to "not available in
311
- * this session" on a missing provider (see `slash.mjs`), so an embedder that
312
- * wires nothing still gets a working `/help` instead of a crash.
313
- */
314
- slashContext = {},
315
- }) {
316
- const interactive = input.isTTY === true;
317
-
318
- // ⚠️ A pipe is drained up front — see readAllLines. Doing this lazily is what
319
- // silently lost every prompt after the first.
320
- const queued = interactive ? null : await readAllLines(input);
321
- let queueIndex = 0;
322
-
323
- /**
324
- * ── ⚠️⚠️ READLINE IS GONE FROM THE INTERACTIVE PATH, AND IT HAD TO GO ──────
325
- *
326
- * `input-box.mjs` now owns the keyboard. Leaving the readline interface
327
- * attached to the SAME stream was not merely redundant — both it and the box
328
- * saw every Ctrl-C, so `onInterrupt` fired TWICE for one keypress. Measured,
329
- * not theorised: a single `\x03` produced `['interrupt', 'interrupt']`.
330
- *
331
- * ⭐ A DOUBLE INTERRUPT IS NOT A COSMETIC BUG. The second Ctrl-C is the one
332
- * that QUITS so one press would have armed and fired the escape hatch in the
333
- * same instant, ending a session the user meant only to nudge.
334
- *
335
- * The Node-internals note below about `question()` swallowing Ctrl-C is kept
336
- * because it is why the box reads raw keys itself rather than asking readline
337
- * for a line. It is history now, not a live constraint.
338
- */
339
- const rl = null;
340
- const state = { closed: false };
341
- /**
342
- * ⚠️ ATTACHED ONCE, NOT PER QUESTION — and the per-question version is why the
343
- * first fix did not work. The stream can end WHILE the model call is in
344
- * flight, when no question is pending and therefore no listener is attached;
345
- * `close` fires into nothing, the flag stays false, and the next question
346
- * throws anyway. A session-lifetime listener sees it whenever it happens.
347
- */
348
- if (rl) rl.once('close', () => { state.closed = true; });
349
- /**
350
- * ⭐ THE ONE LINE THAT MAKES CTRL-C REACH THE RUN — see `deliverInterrupt`
351
- * above for the Node source that proves it is needed. `on`, not `once`: an
352
- * interactive session runs many turns and the SECOND Ctrl-C (the one that
353
- * quits) has to arrive here too, or the escape hatch is a single-use one.
354
- */
355
- if (rl) rl.on('SIGINT', () => { onInterrupt(); });
356
- /**
357
- * ⚠️⚠️ THE BANNER IS DEFERRED UNTIL AFTER THE PIN, AND THE ORDER IS NOT
358
- * COSMETIC. `pinRegion` CLEARS the screen so the transcript starts at the top
359
- * of a clean one printed before that, the banner is erased by the very
360
- * thing meant to sit beneath it, which is exactly what a screenshot showed:
361
- * a banner cut in half with the conversation crammed at the bottom.
362
- */
363
- const writeBanner = () => { if (banner) output.write(`${banner}\n`); };
364
- // ⚠️ `/help` IS ADVERTISED IN THE ONE LINE EVERY SESSION PRINTS. A command
365
- // surface nobody is told about is the same defect item 14 closed for `--help`:
366
- // the feature worked and nothing a stranger would read mentioned it.
367
- /**
368
- * ⚠️ A BLANK LINE BEFORE IT, not just after. Roman: *"move it one line down."*
369
- * Pressed against the banner it read as a fifth detail row rather than as an
370
- * instruction addressed to the person.
371
- */
372
- output.write('\nType what you want done. "/help" for commands, "exit" to leave.\n\n');
373
-
374
- let history = null;
375
- let turns = 0;
376
- /**
377
- * ⭐ SET BY `/skills <name>`, CONSUMED BY THE NEXT REAL TURN AND THEN CLEARED.
378
- * A skill that was printed to the terminal would look loaded and be invisible
379
- * to the model; this is the variable that makes the verb real.
380
- */
381
- let pendingInject = null;
382
- /** Lines the user has submitted this session — the box's Up/Down history. */
383
- const typed = [];
384
-
385
- /**
386
- * ── ⭐⭐⭐ THE BOX IS PINNED TO THE BOTTOM OF THE SCREEN ────────────────────
387
- *
388
- * Roman: *"we need that prompt box stuck down the bottom, it is professional."*
389
- *
390
- * `pinRegion` reserves the last three rows, so output scrolls ABOVE the box
391
- * and the box itself never moves. Off a TTY, in CI, on a short terminal, or
392
- * with ACUVO_NO_PIN=1 it does nothing at all.
393
- *
394
- * ⚠️⚠️ RELEASED ON EVERY PATH OUT, INCLUDING THE ONES NOBODY PLANS FOR. A
395
- * process that exits with a scroll region still set leaves the user with a
396
- * terminal that scrolls inside a box until they type `reset` blind the same
397
- * class of harm as leaving raw mode on. The `finally` covers a normal end and
398
- * a throw; the signal handlers cover Ctrl-C and `kill`.
399
- */
400
- const pin = interactive ? pinRegion(output) : { enabled: false, release() {} };
401
- const releasePin = () => pin.release();
402
- if (pin.enabled) {
403
- process.once('exit', releasePin);
404
- process.once('SIGINT', releasePin);
405
- process.once('SIGTERM', releasePin);
406
- }
407
-
408
- /**
409
- * ⭐ NOW the banner, at the top of a screen the pin has just cleared, with the
410
- * conversation free to grow downward beneath it which is the direction every
411
- * scrollback in existence runs.
412
- */
413
- writeBanner();
414
-
415
- try {
416
- for (;;) {
417
- /**
418
- * ── ⭐⭐ THE INPUT IS A BOX, BECAUSE A BARE `› ` IS NOT A PLACE TO TYPE ──
419
- *
420
- * Roman, comparing against a real Claude Code screenshot: *"you can see
421
- * the box where you type, acuvo doesn't have that."* He is right — the
422
- * prompt was two characters floating in the scrollback, which reads as
423
- * output rather than as somewhere input goes.
424
- *
425
- * ⚠️ THE BOX OPENS BEFORE THE LINE AND CLOSES AFTER IT, deliberately.
426
- * Drawing all four sides up front needs absolute cursor control, and
427
- * readline owns the cursor once `question()` starts every version of
428
- * that fights the line editor the moment input wraps, a history entry is
429
- * recalled, or the window is resized. Opening on entry and closing on
430
- * submit needs no cursor math at all, survives all three, and leaves a
431
- * transcript where each turn is visibly a closed unit.
432
- *
433
- * ⚠️ WIDTH IS READ FRESH EACH TURN — a terminal resized mid-session would
434
- * otherwise draw rules at the old width for the rest of the run. Clamped,
435
- * because `columns` is `undefined` when stdout is not a TTY and enormous
436
- * when someone maximises on an ultrawide.
437
- */
438
- let line;
439
- if (interactive) {
440
- output.write('\n');
441
- const got = await readBoxedLine({
442
- atRow: pin.enabled ? pin.bottom + 1 : 0,
443
- input,
444
- output,
445
- history: typed,
446
- onInterrupt,
447
- });
448
- line = got.value;
449
- } else {
450
- line = queueIndex < queued.length ? queued[queueIndex++] : null;
451
- }
452
- // Echo a piped prompt so a scripted transcript reads like a session.
453
- if (!interactive && line !== null && line.trim()) output.write(`› ${line.trim()}
454
- `);
455
- // ⚠️ EOF is not an error. A closed pipe or Ctrl-D ends the session the
456
- // same way "exit" does — treating it as a fault would print a stack trace
457
- // at the end of every scripted run.
458
- if (line === null) break;
459
- const task = line.trim();
460
- if (!task) continue;
461
- // ⚠️ Deduped against the PREVIOUS entry only: pressing Up should walk
462
- // distinct instructions, not scroll through five copies of `npm test`.
463
- if (typed[typed.length - 1] !== task) typed.push(task);
464
- if (QUIT.has(task.toLowerCase())) break;
465
-
466
- /**
467
- * ── THE `/` SURFACE, BEFORE ANYTHING IS SENT TO A MODEL ────────────
468
- *
469
- * ⚠️ IT COSTS NOTHING AND MUST NOT COUNT AS A TURN. `/cost` is asked
470
- * precisely by somebody watching their spend, and answering it by
471
- * incrementing the turn counter and appending to the history would make
472
- * the question change the answer.
473
- *
474
- * ⚠️ AN UNRECOGNISED COMMAND IS ANSWERED HERE AND NOT FORWARDED. Passing
475
- * `/skil` to the model gets a confident essay about a typo; the one thing
476
- * the person needed was the word `/skills`, which `slash.mjs` supplies.
477
- * See its header for why `/etc/hosts` is NOT treated as a command.
478
- */
479
- const command = parseSlash(line.trim());
480
- if (command) {
481
- const result = runSlashCommand(command, slashContext);
482
- for (const l of result.output ?? []) output.write(`${l}\n`);
483
- output.write('\n');
484
- if (result.effect === 'clear') history = null;
485
- if (typeof result.inject === 'string' && result.inject) pendingInject = result.inject;
486
- continue;
487
- }
488
-
489
- /**
490
- * ⚠️ THE SKILL IS PREPENDED TO THE TASK, NOT SUBSTITUTED FOR IT. The user
491
- * typed an instruction; the skill is context for it. And it is cleared
492
- * BEFORE the call rather than after, so a turn that throws cannot leave it
493
- * armed and silently attach it to an unrelated question later.
494
- */
495
- let sendTask = task;
496
- if (pendingInject) {
497
- sendTask = `${pendingInject}\n\n---\n\n${task}`;
498
- pendingInject = null;
499
- }
500
-
501
- let outcome;
502
- try {
503
- outcome = await runOne(sendTask, history);
504
- } catch (err) {
505
- /**
506
- * ⚠️ ONE BAD TURN MUST NOT END THE SESSION. A timeout or a provider blip
507
- * after twenty minutes of context is infuriating if it drops everything;
508
- * the history is still valid, so report and keep the prompt.
509
- */
510
- output.write(`\n ✖ that turn failed: ${String(err?.message || err)}\n\n`);
511
- continue;
512
- }
513
-
514
- turns += 1;
515
- render(outcome, output);
516
-
517
- if (outcome?.ok && Array.isArray(outcome.messages)) {
518
- history = trimHistory(outcome.messages, maxHistory);
519
- } else if (!outcome?.ok) {
520
- // A failed turn leaves history UNTOUCHED. Appending a turn that produced
521
- // nothing would poison the next one with a dead exchange.
522
- output.write(`\n (history unchanged — that turn did not complete)\n`);
523
- }
524
- output.write('\n');
525
- }
526
- } finally {
527
- if (rl) rl.close();
528
- pin.release();
529
- // ⚠️ The listeners come off too a long-lived process that started several
530
- // sessions would otherwise accumulate them and warn about a leak.
531
- process.off('exit', releasePin);
532
- process.off('SIGINT', releasePin);
533
- process.off('SIGTERM', releasePin);
534
- }
535
- return { turns };
536
- }
1
+ /**
2
+ * ── ⭐ THE INTERACTIVE SESSION ───────────────────────────────────────────────
3
+ *
4
+ * Every invocation of `acuvo "task"` started COLD: it re-gathered the workspace,
5
+ * rebuilt the prompt, and knew nothing about the last thing you asked. So the
6
+ * second instruction cost as much as the first, and "now do the same for the
7
+ * other file" was not a sentence you could say.
8
+ *
9
+ * ⭐ AND THE ECONOMICS ARE THE ARGUMENT, NOT JUST THE ERGONOMICS. Measured
10
+ * 2026-08-09: an identical prompt prefix cached at **97.2%**, dropping the call
11
+ * cost **4.3x** ($0.000836 → $0.000195). A session that APPENDS keeps that
12
+ * prefix intact, so every turn after the first is nearly free. A tool that
13
+ * rebuilds its prompt each time throws that away and looks identical from the
14
+ * outside — which is exactly why this is worth building rather than assuming.
15
+ *
16
+ * ── ⚠️ WHY NOT A FULL TUI ────────────────────────────────────────────────────
17
+ * `readline` and plain writes, no alternate screen buffer, no cursor addressing.
18
+ * A TUI that redraws breaks `>` redirection, breaks piping into a file, breaks
19
+ * `tee`, and breaks every terminal that is not the one it was tested in. The
20
+ * output here is append-only text, so a session transcript is a file you can
21
+ * keep. That is a deliberate trade of polish for portability.
22
+ */
23
+
24
+ import { createInterface } from 'node:readline';
25
+ import { readBoxedLine, pinRegion } from './input-box.mjs';
26
+ import { createPainter, colourEnabled } from './colour.mjs';
27
+ import { EXIT_INTERRUPTED } from './interrupt.mjs';
28
+ import { parseSlash, runSlashCommand, SLASH_COMMANDS } from './slash.mjs';
29
+ import { estimateMessagesTokens } from './compact.mjs';
30
+
31
+ /** What ends a session. `exit`/`quit` because both are muscle memory. */
32
+ const QUIT = new Set(['exit', 'quit', ':q', 'bye']);
33
+
34
+ /**
35
+ * ⚠️ CONTEXT GROWS UNBOUNDED AND A CODING SESSION IS THE WORST CASE — tool
36
+ * results carry whole files. Left alone, turn 30 sends everything from turns
37
+ * 1-29 and eventually 400s on a context-length error mid-thought.
38
+ *
39
+ * The trim keeps the SYSTEM message and the FIRST user message (the workspace
40
+ * context the cacheable prefix, dropping it would cost more than it saves) and
41
+ * discards the oldest middle turns.
42
+ */
43
+ export const MAX_HISTORY_MESSAGES = 40;
44
+
45
+ /**
46
+ * ── 💰⭐⭐⭐ THE TRIM WAS A CONTINUOUS SLIDE, AND IT COST ~90% OF THE CACHE ──
47
+ *
48
+ * `messages.slice(-keep)` re-slices on EVERY turn once a session passes the
49
+ * cap, so the third message of the prompt is different every time. Prefix
50
+ * caching matches from the first token and stops at the first difference — so
51
+ * everything after the 2-message head was re-bought at full price, every turn,
52
+ * for the rest of the session.
53
+ *
54
+ * MEASURED (no credits, no network — a simulated 60-turn session counting how
55
+ * much of each prompt is byte-identical to the previous one):
56
+ *
57
+ * CURRENT slice(-38) cache 9.2% history 38-40
58
+ * stepped high=48 low=40 cache 74.5% history 38-48
59
+ * stepped high=56 low=40 cache 85.3% history 38-56
60
+ * stepped high=64 low=40 cache 89.7% history 38-64 <- shipped
61
+ * stepped high=72 low=40 cache 92.0% history 38-72
62
+ *
63
+ * ⚠️ THE HEAD BEING STABLE IS NOT THE SAME AS THE PROMPT BEING CACHED, and
64
+ * measuring position 0 alone would have said this code was fine. The 2-message
65
+ * head never moved; the cacheable prefix was still 2 messages out of 40.
66
+ *
67
+ * `LOW` IS TODAY'S CAP ON PURPOSE, so this can only ever keep MORE history
68
+ * than the rule it replaces never less. That makes it a pure win rather than
69
+ * a trade of memory for money, and it is why HIGH was raised instead of LOW
70
+ * being lowered (console shipped 16/8, halving its window; the CLI holds whole
71
+ * files in tool results and cannot afford to forget more).
72
+ *
73
+ * ⚠️ RAISING `HIGH` COSTS TOKENS PER REQUEST, and the ceiling that matters is
74
+ * not here: `turn.mjs` compacts above CONTEXT_BUDGET_TOKENS (96k), which
75
+ * `compact.mjs` warns is the moment the cache discount dies for good. This is a
76
+ * MESSAGE-COUNT backstop, not the token bound 64 messages of ordinary turns
77
+ * sit far under 96k, but a session whose tool results carry whole files can
78
+ * approach it, and then compaction (with its own hysteresis) takes over. 72 and
79
+ * 80 buy 2 more points of cache for materially more of that risk, which is why
80
+ * 64 is the shipped number and not the best one in the table.
81
+ */
82
+ export const HISTORY_HIGH_WATER = 64;
83
+ export const HISTORY_LOW_WATER = MAX_HISTORY_MESSAGES;
84
+
85
+ /**
86
+ * ⚠️ WELL UNDER `turn.mjs`'s CONTEXT_BUDGET_TOKENS (96,000), not near it. The
87
+ * request also carries the TOOL OFFER measured at ~15,200 tokens for the
88
+ * CLI's 63 verbs — plus the system prompt, so history must leave room for both
89
+ * and still clear the line where compaction fires. 55k + ~15k offer + prompt
90
+ * sits comfortably inside 96k.
91
+ *
92
+ * This is a CEILING, not a target: an ordinary session (~551 tokens/message,
93
+ * measured) reaches the 64-message cap at ~35k and never comes near it. It
94
+ * exists for the file-heavy session, which is exactly the one that would
95
+ * otherwise be pushed into permanent compaction by the higher message cap.
96
+ */
97
+ export const HISTORY_TOKEN_CEILING = 55_000;
98
+
99
+ export function trimHistory(messages, max = HISTORY_HIGH_WATER) {
100
+ if (!Array.isArray(messages)) return messages;
101
+ /**
102
+ * ⚠️⚠️ THE GATE MUST ASK BOTH QUESTIONS, AND MY FIRST VERSION ASKED ONLY ONE.
103
+ * It read `messages.length <= max` and returned early so the token ceiling
104
+ * below was unreachable for exactly the session it was written for: 64 heavy
105
+ * messages are under the COUNT cap and ~137,000 tokens, and the function
106
+ * handed them straight back. The test reported 133,491 tokens and I had to
107
+ * measure to find that the ceiling was never consulted at all rather than
108
+ * being wrong.
109
+ */
110
+ const overBudget = estimateMessagesTokens(messages) > HISTORY_TOKEN_CEILING;
111
+ if (messages.length <= max && !overBudget) return messages;
112
+ const head = messages.slice(0, 2); // system + the context-bearing user turn
113
+ /**
114
+ * THE HEAD MOVES IN STEPS, NOT EVERY TURN. `dropped` is a multiple of STEP,
115
+ * so it changes only when the conversation crosses the next boundary — and
116
+ * between boundaries the prompt is byte-identical to the previous turn's.
117
+ *
118
+ * ⚠️ NOT `length > HIGH ? slice(-LOW)`. That is still a continuous slide past
119
+ * the mark and is the exact mistake console's own guard records catching
120
+ * before it shipped.
121
+ */
122
+ /**
123
+ * ⚠️⚠️ THE LOW WATER MUST BE DERIVED FROM `max`, NOT PINNED TO A CONSTANT,
124
+ * AND I ALMOST SHIPPED IT PINNED. My first version was
125
+ * `Math.min(HISTORY_LOW_WATER, max)`. `runChat`'s default `maxHistory` was
126
+ * MAX_HISTORY_MESSAGES (40), which equals HISTORY_LOW_WATER so `step`
127
+ * became `max(1, 0)` = 1, and one-message steps ARE the continuous slide this
128
+ * whole change exists to remove. The fix would have measured 89.6% in a
129
+ * simulation and done NOTHING on the only path that calls it.
130
+ *
131
+ * 0.625 is 40/64 — the shipped ratio, so the default keeps exactly the
132
+ * numbers that were measured, and any other `max` still steps properly
133
+ * instead of silently degrading to a slide.
134
+ */
135
+ const low = Math.min(HISTORY_LOW_WATER, Math.floor(max * 0.625));
136
+ const step = Math.max(1, max - low);
137
+ const body = messages.slice(head.length);
138
+ const overflow = Math.max(0, messages.length - max);
139
+ let dropped = Math.ceil(overflow / step) * step;
140
+ /**
141
+ * ── ⚠️⚠️ AND A TOKEN CEILING, BECAUSE MESSAGE COUNT IS THE WRONG UNIT ──────
142
+ *
143
+ * MEASURED against the one real recorded session on disk (93,036 prompt
144
+ * tokens, 18 messages, `.acuvo/sessions/20260814-095636-99d4.json`):
145
+ *
146
+ * avg message ~551 tokens largest observed ~2,145 tokens
147
+ *
148
+ * at 40 msgs: typical ~22,000 worst case (all like the largest) ~85,800
149
+ * at 64 msgs: typical ~35,300 worst case ~137,300
150
+ *
151
+ * ⚠️ SO RAISING THE COUNT ALONE MOVES THE WORST CASE FROM JUST UNDER THE
152
+ * 96k CONTEXT BUDGET TO WELL OVER IT. Past that line `turn.mjs` compacts, and
153
+ * `compact.mjs` is explicit that compaction voids the cache discount and
154
+ * "once it starts, it never stops" so a change made ENTIRELY to protect the
155
+ * cache would, in a file-heavy session, destroy it. A tool result carrying a
156
+ * whole file is not the exception in a coding session; it is the normal case.
157
+ *
158
+ * THE CEILING DROPS IN THE SAME `step` BLOCKS. Any multiple of `step`
159
+ * keeps block alignment, so the prefix still changes only at boundaries and
160
+ * the caching win is untouched — this bounds the worst case without
161
+ * reintroducing a slide.
162
+ */
163
+ const budget = Math.max(1, HISTORY_TOKEN_CEILING - estimateMessagesTokens(head));
164
+ while (dropped < body.length && estimateMessagesTokens(body.slice(dropped)) > budget) {
165
+ dropped += step;
166
+ }
167
+ let tail = body.slice(dropped);
168
+ /**
169
+ * ⚠️ A `tool` MESSAGE WITHOUT ITS `assistant` TOOL CALL IS A HARD 400 from
170
+ * every OpenAI-shaped provider "tool_call_id did not have a preceding
171
+ * message with tool_calls". Slicing mid-exchange produces exactly that, and it
172
+ * would surface as a mysterious API error thirty turns into a session.
173
+ */
174
+ while (tail.length > 0 && tail[0].role === 'tool') tail = tail.slice(1);
175
+ return [...head, ...tail];
176
+ }
177
+
178
+ /**
179
+ * One prompt line. Returns null on EOF (Ctrl-D, or a pipe that ran out).
180
+ *
181
+ * ⚠️ THE CLOSED CHECK IS NOT DEFENSIVE, IT IS THE PIPED CASE. Found by piping a
182
+ * list of prompts in: readline emits 'close' when the stream ends, and the NEXT
183
+ * `rl.question()` throws ERR_USE_AFTER_CLOSE. The first turn had already
184
+ * succeeded and written a real file, so the session crashed AFTER doing its job
185
+ * — the worst shape of failure, because the work looks lost.
186
+ *
187
+ * Scripted input matters beyond tests: piping a prompt list is how anyone would
188
+ * automate this.
189
+ */
190
+ function ask(rl, prompt, state) {
191
+ if (state.closed) return Promise.resolve(null);
192
+ return new Promise((resolve) => {
193
+ let answered = false;
194
+ const onClose = () => { if (!answered) resolve(null); };
195
+ rl.once('close', onClose);
196
+ rl.question(prompt, (line) => {
197
+ answered = true;
198
+ rl.removeListener('close', onClose);
199
+ resolve(line);
200
+ });
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Run an interactive session.
206
+ *
207
+ * `runOne(task, priorMessages)` performs one turn and returns the session
208
+ * outcome injected rather than imported so this loop is testable with a stub
209
+ * and never needs a model or a terminal in a test.
210
+ */
211
+ /**
212
+ * ── ⚠️ PIPED INPUT IS A DIFFERENT PROBLEM AND NEEDED A DIFFERENT ANSWER ──────
213
+ * `readline` on a non-TTY DRAINS the stream as fast as it can and emits 'close'
214
+ * the moment it ends. The model call for turn 1 takes seconds, by which point
215
+ * the interface is already closed and every later prompt is lost — measured:
216
+ * a three-line pipe ran exactly ONE turn and exited quietly, which is worse than
217
+ * crashing because it looks like it worked.
218
+ *
219
+ * So a pipe is read WHOLE and replayed from a queue. A TTY keeps the real
220
+ * readline loop, where a human types the next line after seeing the last answer.
221
+ * Two input shapes, two mechanisms — pretending they are the same is what broke.
222
+ */
223
+ async function readAllLines(input) {
224
+ const chunks = [];
225
+ for await (const chunk of input) chunks.push(chunk);
226
+ return Buffer.concat(chunks.map((c) => (typeof c === 'string' ? Buffer.from(c) : c)))
227
+ .toString('utf8')
228
+ .split(String.fromCharCode(10))
229
+ .map((l) => l.replace(String.fromCharCode(13), ''));
230
+ }
231
+
232
+ /**
233
+ * ── ⚠️⚠️⭐ READLINE EATS CTRL-C. MEASURED IN NODE'S OWN SOURCE ──────────────
234
+ *
235
+ * This is the finding that made an interrupt handler in `bin/acuvo.mjs`
236
+ * necessary-but-not-sufficient. Read out of `process.binding('natives')` on
237
+ * node v22.17.0, `internal/readline/interface.js`, the ttyWrite ctrl-key
238
+ * switch, verbatim:
239
+ *
240
+ * case 'c':
241
+ * if (this.listenerCount('SIGINT') > 0) {
242
+ * this.emit('SIGINT');
243
+ * } else {
244
+ * // This readline instance is finished
245
+ * this.close();
246
+ * this[kQuestionReject]?.(new AbortError('Aborted with Ctrl+C'));
247
+ * }
248
+ *
249
+ * ⚠️ So with a TTY readline open and NO `'SIGINT'` listener on the interface,
250
+ * Ctrl-C never reaches `process.on('SIGINT')` at all readline just closes
251
+ * itself. The run in flight would have carried on to completion, for minutes,
252
+ * with the user's Ctrl-C having produced nothing on screen. That is strictly
253
+ * worse than the bug we set out to fix, and no amount of correct handling in
254
+ * `bin/acuvo.mjs` would have been reached.
255
+ *
256
+ * So the interface takes a listener whose whole job is to hand the signal
257
+ * back to the process, where the one policy in `interrupt.mjs` decides between
258
+ * "stop after this round" and "quit now".
259
+ *
260
+ * ── ⚠️⚠️ AND A SYNTHETIC EMIT INTO AN EMPTY EMITTER DOES NOTHING ────────────
261
+ *
262
+ * `process.emit('SIGINT')` is plain `EventEmitter.emit` it does NOT invoke
263
+ * the OS default action. `turn.mjs` installs the process signal handlers inside
264
+ * `runSession`, so before the first turn of a session there are ZERO listeners
265
+ * and the emit would return `false` having done absolutely nothing. Ctrl-C at
266
+ * the very first prompt would be inert.
267
+ *
268
+ * Hence the count check and the explicit exit: **every path out of this
269
+ * function either aborts a run or ends the process.** That is the rule this
270
+ * whole feature is built on, and it is the one that is easy to break here.
271
+ */
272
+ export function deliverInterrupt({
273
+ emit = (sig) => process.emit(sig),
274
+ listenerCount = (sig) => process.listenerCount(sig),
275
+ exit = (code) => process.exit(code),
276
+ } = {}) {
277
+ if (listenerCount('SIGINT') > 0) {
278
+ emit('SIGINT');
279
+ return 'delegated';
280
+ }
281
+ exit(EXIT_INTERRUPTED);
282
+ return 'exited';
283
+ }
284
+
285
+ export async function runChat({
286
+ runOne,
287
+ render,
288
+ input = process.stdin,
289
+ output = process.stdout,
290
+ banner = '',
291
+ /**
292
+ * ⚠️ THE HIGH WATER MARK, NOT THE OLD FLAT CAP. This default is what makes
293
+ * the stepped trim REACH the live session see the note in `trimHistory`
294
+ * about the version of this fix that measured 89.6% and changed nothing.
295
+ */
296
+ maxHistory = HISTORY_HIGH_WATER,
297
+ /**
298
+ * ⚠️ INJECTED SO A TEST CAN SEE IT. The real one exits the process, and a
299
+ * test that could not substitute it could only assert this feature by killing
300
+ * its own runner. Default is production behaviour, so no caller changes.
301
+ */
302
+ onInterrupt = deliverInterrupt,
303
+ /**
304
+ * ── ⭐ THE `/` SURFACE'S ONE SEAM ─────────────────────────────────────────
305
+ *
306
+ * Providers for the things a command reports on skills, MCP servers, spend,
307
+ * the model. `bin/` owns where those facts come from; this loop only asks, for
308
+ * the same reason `workspace.mjs` takes `claimPath` and `journal` injected
309
+ * rather than importing them.
310
+ *
311
+ * ⚠️ `{}` BY DEFAULT, NOT `null`. Every command degrades to "not available in
312
+ * this session" on a missing provider (see `slash.mjs`), so an embedder that
313
+ * wires nothing still gets a working `/help` instead of a crash.
314
+ */
315
+ slashContext = {},
316
+ }) {
317
+ const interactive = input.isTTY === true;
318
+
319
+ // ⚠️ A pipe is drained up front — see readAllLines. Doing this lazily is what
320
+ // silently lost every prompt after the first.
321
+ const queued = interactive ? null : await readAllLines(input);
322
+ let queueIndex = 0;
323
+
324
+ /**
325
+ * ── ⚠️⚠️ READLINE IS GONE FROM THE INTERACTIVE PATH, AND IT HAD TO GO ──────
326
+ *
327
+ * `input-box.mjs` now owns the keyboard. Leaving the readline interface
328
+ * attached to the SAME stream was not merely redundant both it and the box
329
+ * saw every Ctrl-C, so `onInterrupt` fired TWICE for one keypress. Measured,
330
+ * not theorised: a single `\x03` produced `['interrupt', 'interrupt']`.
331
+ *
332
+ * A DOUBLE INTERRUPT IS NOT A COSMETIC BUG. The second Ctrl-C is the one
333
+ * that QUITS so one press would have armed and fired the escape hatch in the
334
+ * same instant, ending a session the user meant only to nudge.
335
+ *
336
+ * The Node-internals note below about `question()` swallowing Ctrl-C is kept
337
+ * because it is why the box reads raw keys itself rather than asking readline
338
+ * for a line. It is history now, not a live constraint.
339
+ */
340
+ const rl = null;
341
+ const state = { closed: false };
342
+ /**
343
+ * ⚠️ ATTACHED ONCE, NOT PER QUESTION and the per-question version is why the
344
+ * first fix did not work. The stream can end WHILE the model call is in
345
+ * flight, when no question is pending and therefore no listener is attached;
346
+ * `close` fires into nothing, the flag stays false, and the next question
347
+ * throws anyway. A session-lifetime listener sees it whenever it happens.
348
+ */
349
+ if (rl) rl.once('close', () => { state.closed = true; });
350
+ /**
351
+ * THE ONE LINE THAT MAKES CTRL-C REACH THE RUN see `deliverInterrupt`
352
+ * above for the Node source that proves it is needed. `on`, not `once`: an
353
+ * interactive session runs many turns and the SECOND Ctrl-C (the one that
354
+ * quits) has to arrive here too, or the escape hatch is a single-use one.
355
+ */
356
+ if (rl) rl.on('SIGINT', () => { onInterrupt(); });
357
+ /**
358
+ * ⚠️⚠️ THE BANNER IS DEFERRED UNTIL AFTER THE PIN, AND THE ORDER IS NOT
359
+ * COSMETIC. `pinRegion` CLEARS the screen so the transcript starts at the top
360
+ * of a clean one printed before that, the banner is erased by the very
361
+ * thing meant to sit beneath it, which is exactly what a screenshot showed:
362
+ * a banner cut in half with the conversation crammed at the bottom.
363
+ */
364
+ const writeBanner = () => { if (banner) output.write(`${banner}\n`); };
365
+ // ⚠️ `/help` IS ADVERTISED IN THE ONE LINE EVERY SESSION PRINTS. A command
366
+ // surface nobody is told about is the same defect item 14 closed for `--help`:
367
+ // the feature worked and nothing a stranger would read mentioned it.
368
+ /**
369
+ * ⚠️ A BLANK LINE BEFORE IT, not just after. Roman: *"move it one line down."*
370
+ * Pressed against the banner it read as a fifth detail row rather than as an
371
+ * instruction addressed to the person.
372
+ */
373
+ output.write('\nType what you want done. "/help" for commands, "exit" to leave.\n\n');
374
+
375
+ let history = null;
376
+ let turns = 0;
377
+ /**
378
+ * SET BY `/skills <name>`, CONSUMED BY THE NEXT REAL TURN AND THEN CLEARED.
379
+ * A skill that was printed to the terminal would look loaded and be invisible
380
+ * to the model; this is the variable that makes the verb real.
381
+ */
382
+ let pendingInject = null;
383
+ /** Lines the user has submitted this session — the box's Up/Down history. */
384
+ const typed = [];
385
+
386
+ /**
387
+ * ── ⭐⭐⭐ THE BOX IS PINNED TO THE BOTTOM OF THE SCREEN ────────────────────
388
+ *
389
+ * Roman: *"we need that prompt box stuck down the bottom, it is professional."*
390
+ *
391
+ * `pinRegion` reserves the last three rows, so output scrolls ABOVE the box
392
+ * and the box itself never moves. Off a TTY, in CI, on a short terminal, or
393
+ * with ACUVO_NO_PIN=1 it does nothing at all.
394
+ *
395
+ * ⚠️⚠️ RELEASED ON EVERY PATH OUT, INCLUDING THE ONES NOBODY PLANS FOR. A
396
+ * process that exits with a scroll region still set leaves the user with a
397
+ * terminal that scrolls inside a box until they type `reset` blind the same
398
+ * class of harm as leaving raw mode on. The `finally` covers a normal end and
399
+ * a throw; the signal handlers cover Ctrl-C and `kill`.
400
+ */
401
+ const pin = interactive ? pinRegion(output) : { enabled: false, release() {} };
402
+ const releasePin = () => pin.release();
403
+ if (pin.enabled) {
404
+ process.once('exit', releasePin);
405
+ process.once('SIGINT', releasePin);
406
+ process.once('SIGTERM', releasePin);
407
+ }
408
+
409
+ /**
410
+ * NOW the banner, at the top of a screen the pin has just cleared, with the
411
+ * conversation free to grow downward beneath it — which is the direction every
412
+ * scrollback in existence runs.
413
+ */
414
+ writeBanner();
415
+
416
+ try {
417
+ for (;;) {
418
+ /**
419
+ * ── ⭐⭐ THE INPUT IS A BOX, BECAUSE A BARE `› ` IS NOT A PLACE TO TYPE ──
420
+ *
421
+ * Roman, comparing against a real Claude Code screenshot: *"you can see
422
+ * the box where you type, acuvo doesn't have that."* He is right — the
423
+ * prompt was two characters floating in the scrollback, which reads as
424
+ * output rather than as somewhere input goes.
425
+ *
426
+ * ⚠️ THE BOX OPENS BEFORE THE LINE AND CLOSES AFTER IT, deliberately.
427
+ * Drawing all four sides up front needs absolute cursor control, and
428
+ * readline owns the cursor once `question()` starts every version of
429
+ * that fights the line editor the moment input wraps, a history entry is
430
+ * recalled, or the window is resized. Opening on entry and closing on
431
+ * submit needs no cursor math at all, survives all three, and leaves a
432
+ * transcript where each turn is visibly a closed unit.
433
+ *
434
+ * ⚠️ WIDTH IS READ FRESH EACH TURN a terminal resized mid-session would
435
+ * otherwise draw rules at the old width for the rest of the run. Clamped,
436
+ * because `columns` is `undefined` when stdout is not a TTY and enormous
437
+ * when someone maximises on an ultrawide.
438
+ */
439
+ let line;
440
+ if (interactive) {
441
+ output.write('\n');
442
+ const got = await readBoxedLine({
443
+ atRow: pin.enabled ? pin.bottom + 1 : 0,
444
+ /**
445
+ * ⚠️ DERIVED FROM `SLASH_COMMANDS`, NEVER RETYPED. A hardcoded list
446
+ * here would be right today and wrong the first time a command is
447
+ * added — the menu would quietly omit it, which is the same
448
+ * built-but-invisible failure the menu exists to fix.
449
+ */
450
+ commands: SLASH_COMMANDS.map((c) => c.name),
451
+ paintFn: createPainter(colourEnabled({ stream: output })).brand,
452
+ input,
453
+ output,
454
+ history: typed,
455
+ onInterrupt,
456
+ });
457
+ line = got.value;
458
+ } else {
459
+ line = queueIndex < queued.length ? queued[queueIndex++] : null;
460
+ }
461
+ // Echo a piped prompt so a scripted transcript reads like a session.
462
+ if (!interactive && line !== null && line.trim()) output.write(`› ${line.trim()}
463
+ `);
464
+ // ⚠️ EOF is not an error. A closed pipe or Ctrl-D ends the session the
465
+ // same way "exit" does — treating it as a fault would print a stack trace
466
+ // at the end of every scripted run.
467
+ if (line === null) break;
468
+ const task = line.trim();
469
+ if (!task) continue;
470
+ // ⚠️ Deduped against the PREVIOUS entry only: pressing Up should walk
471
+ // distinct instructions, not scroll through five copies of `npm test`.
472
+ if (typed[typed.length - 1] !== task) typed.push(task);
473
+ if (QUIT.has(task.toLowerCase())) break;
474
+
475
+ /**
476
+ * ── THE `/` SURFACE, BEFORE ANYTHING IS SENT TO A MODEL ────────────
477
+ *
478
+ * ⚠️ IT COSTS NOTHING AND MUST NOT COUNT AS A TURN. `/cost` is asked
479
+ * precisely by somebody watching their spend, and answering it by
480
+ * incrementing the turn counter and appending to the history would make
481
+ * the question change the answer.
482
+ *
483
+ * ⚠️ AN UNRECOGNISED COMMAND IS ANSWERED HERE AND NOT FORWARDED. Passing
484
+ * `/skil` to the model gets a confident essay about a typo; the one thing
485
+ * the person needed was the word `/skills`, which `slash.mjs` supplies.
486
+ * See its header for why `/etc/hosts` is NOT treated as a command.
487
+ */
488
+ const command = parseSlash(line.trim());
489
+ if (command) {
490
+ const result = runSlashCommand(command, slashContext);
491
+ for (const l of result.output ?? []) output.write(`${l}\n`);
492
+ output.write('\n');
493
+ if (result.effect === 'clear') history = null;
494
+ if (typeof result.inject === 'string' && result.inject) pendingInject = result.inject;
495
+ continue;
496
+ }
497
+
498
+ /**
499
+ * ⚠️ THE SKILL IS PREPENDED TO THE TASK, NOT SUBSTITUTED FOR IT. The user
500
+ * typed an instruction; the skill is context for it. And it is cleared
501
+ * BEFORE the call rather than after, so a turn that throws cannot leave it
502
+ * armed and silently attach it to an unrelated question later.
503
+ */
504
+ let sendTask = task;
505
+ if (pendingInject) {
506
+ sendTask = `${pendingInject}\n\n---\n\n${task}`;
507
+ pendingInject = null;
508
+ }
509
+
510
+ let outcome;
511
+ try {
512
+ outcome = await runOne(sendTask, history);
513
+ } catch (err) {
514
+ /**
515
+ * ⚠️ ONE BAD TURN MUST NOT END THE SESSION. A timeout or a provider blip
516
+ * after twenty minutes of context is infuriating if it drops everything;
517
+ * the history is still valid, so report and keep the prompt.
518
+ */
519
+ output.write(`\n ✖ that turn failed: ${String(err?.message || err)}\n\n`);
520
+ continue;
521
+ }
522
+
523
+ turns += 1;
524
+ render(outcome, output);
525
+
526
+ if (outcome?.ok && Array.isArray(outcome.messages)) {
527
+ history = trimHistory(outcome.messages, maxHistory);
528
+ } else if (!outcome?.ok) {
529
+ // A failed turn leaves history UNTOUCHED. Appending a turn that produced
530
+ // nothing would poison the next one with a dead exchange.
531
+ output.write(`\n (history unchanged — that turn did not complete)\n`);
532
+ }
533
+ output.write('\n');
534
+ }
535
+ } finally {
536
+ if (rl) rl.close();
537
+ pin.release();
538
+ // ⚠️ The listeners come off too — a long-lived process that started several
539
+ // sessions would otherwise accumulate them and warn about a leak.
540
+ process.off('exit', releasePin);
541
+ process.off('SIGINT', releasePin);
542
+ process.off('SIGTERM', releasePin);
543
+ }
544
+ return { turns };
545
+ }