acuvo-code 0.4.4 → 0.5.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/ENTERPRISE.md CHANGED
@@ -189,7 +189,7 @@ are the numbers to quote. Counting is the first thing a reviewer does.
189
189
 
190
190
  ⚠️ **This said "18 shipped files", then "41", then "90", then "101", then "108", and every
191
191
  one went stale in turn.** The package ships **120 files — 118 in `lib/`, 2 in
192
- `bin/` — about 78091 lines**, with **237 test files** beside them (counted 2026-08-22).
192
+ `bin/` — about 78245 lines**, with **237 test files** beside them (counted 2026-08-22).
193
193
 
194
194
  ⭐ **AND THE 108 WENT STALE IN THE MOST INSTRUCTIVE WAY POSSIBLE: THREE OF THE FILES IT
195
195
  MISSED WERE REACHABLE FROM NOTHING.** `wiring-reach.test.mjs` was naming
package/lib/chat.mjs CHANGED
@@ -1,487 +1,521 @@
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 } 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
- if (banner) output.write(`${banner}\n`);
357
- // ⚠️ `/help` IS ADVERTISED IN THE ONE LINE EVERY SESSION PRINTS. A command
358
- // surface nobody is told about is the same defect item 14 closed for `--help`:
359
- // the feature worked and nothing a stranger would read mentioned it.
360
- output.write('Type what you want done. "/help" for commands, "exit" to leave.\n\n');
361
-
362
- let history = null;
363
- let turns = 0;
364
- /**
365
- * SET BY `/skills <name>`, CONSUMED BY THE NEXT REAL TURN AND THEN CLEARED.
366
- * A skill that was printed to the terminal would look loaded and be invisible
367
- * to the model; this is the variable that makes the verb real.
368
- */
369
- let pendingInject = null;
370
- /** Lines the user has submitted this session the box's Up/Down history. */
371
- const typed = [];
372
-
373
- try {
374
- for (;;) {
375
- /**
376
- * ── ⭐⭐ THE INPUT IS A BOX, BECAUSE A BARE `› ` IS NOT A PLACE TO TYPE ──
377
- *
378
- * Roman, comparing against a real Claude Code screenshot: *"you can see
379
- * the box where you type, acuvo doesn't have that."* He is right — the
380
- * prompt was two characters floating in the scrollback, which reads as
381
- * output rather than as somewhere input goes.
382
- *
383
- * ⚠️ THE BOX OPENS BEFORE THE LINE AND CLOSES AFTER IT, deliberately.
384
- * Drawing all four sides up front needs absolute cursor control, and
385
- * readline owns the cursor once `question()` starts — every version of
386
- * that fights the line editor the moment input wraps, a history entry is
387
- * recalled, or the window is resized. Opening on entry and closing on
388
- * submit needs no cursor math at all, survives all three, and leaves a
389
- * transcript where each turn is visibly a closed unit.
390
- *
391
- * ⚠️ WIDTH IS READ FRESH EACH TURN a terminal resized mid-session would
392
- * otherwise draw rules at the old width for the rest of the run. Clamped,
393
- * because `columns` is `undefined` when stdout is not a TTY and enormous
394
- * when someone maximises on an ultrawide.
395
- */
396
- let line;
397
- if (interactive) {
398
- output.write('\n');
399
- const got = await readBoxedLine({
400
- input,
401
- output,
402
- history: typed,
403
- onInterrupt,
404
- });
405
- line = got.value;
406
- } else {
407
- line = queueIndex < queued.length ? queued[queueIndex++] : null;
408
- }
409
- // Echo a piped prompt so a scripted transcript reads like a session.
410
- if (!interactive && line !== null && line.trim()) output.write(`› ${line.trim()}
411
- `);
412
- // ⚠️ EOF is not an error. A closed pipe or Ctrl-D ends the session the
413
- // same way "exit" does treating it as a fault would print a stack trace
414
- // at the end of every scripted run.
415
- if (line === null) break;
416
- const task = line.trim();
417
- if (!task) continue;
418
- // ⚠️ Deduped against the PREVIOUS entry only: pressing Up should walk
419
- // distinct instructions, not scroll through five copies of `npm test`.
420
- if (typed[typed.length - 1] !== task) typed.push(task);
421
- if (QUIT.has(task.toLowerCase())) break;
422
-
423
- /**
424
- * ── ⭐ THE `/` SURFACE, BEFORE ANYTHING IS SENT TO A MODEL ────────────
425
- *
426
- * ⚠️ IT COSTS NOTHING AND MUST NOT COUNT AS A TURN. `/cost` is asked
427
- * precisely by somebody watching their spend, and answering it by
428
- * incrementing the turn counter and appending to the history would make
429
- * the question change the answer.
430
- *
431
- * ⚠️ AN UNRECOGNISED COMMAND IS ANSWERED HERE AND NOT FORWARDED. Passing
432
- * `/skil` to the model gets a confident essay about a typo; the one thing
433
- * the person needed was the word `/skills`, which `slash.mjs` supplies.
434
- * See its header for why `/etc/hosts` is NOT treated as a command.
435
- */
436
- const command = parseSlash(line.trim());
437
- if (command) {
438
- const result = runSlashCommand(command, slashContext);
439
- for (const l of result.output ?? []) output.write(`${l}\n`);
440
- output.write('\n');
441
- if (result.effect === 'clear') history = null;
442
- if (typeof result.inject === 'string' && result.inject) pendingInject = result.inject;
443
- continue;
444
- }
445
-
446
- /**
447
- * ⚠️ THE SKILL IS PREPENDED TO THE TASK, NOT SUBSTITUTED FOR IT. The user
448
- * typed an instruction; the skill is context for it. And it is cleared
449
- * BEFORE the call rather than after, so a turn that throws cannot leave it
450
- * armed and silently attach it to an unrelated question later.
451
- */
452
- let sendTask = task;
453
- if (pendingInject) {
454
- sendTask = `${pendingInject}\n\n---\n\n${task}`;
455
- pendingInject = null;
456
- }
457
-
458
- let outcome;
459
- try {
460
- outcome = await runOne(sendTask, history);
461
- } catch (err) {
462
- /**
463
- * ⚠️ ONE BAD TURN MUST NOT END THE SESSION. A timeout or a provider blip
464
- * after twenty minutes of context is infuriating if it drops everything;
465
- * the history is still valid, so report and keep the prompt.
466
- */
467
- output.write(`\n ✖ that turn failed: ${String(err?.message || err)}\n\n`);
468
- continue;
469
- }
470
-
471
- turns += 1;
472
- render(outcome, output);
473
-
474
- if (outcome?.ok && Array.isArray(outcome.messages)) {
475
- history = trimHistory(outcome.messages, maxHistory);
476
- } else if (!outcome?.ok) {
477
- // A failed turn leaves history UNTOUCHED. Appending a turn that produced
478
- // nothing would poison the next one with a dead exchange.
479
- output.write(`\n (history unchanged — that turn did not complete)\n`);
480
- }
481
- output.write('\n');
482
- }
483
- } finally {
484
- if (rl) rl.close();
485
- }
486
- return { turns };
487
- }
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
+ if (banner) output.write(`${banner}\n`);
357
+ // ⚠️ `/help` IS ADVERTISED IN THE ONE LINE EVERY SESSION PRINTS. A command
358
+ // surface nobody is told about is the same defect item 14 closed for `--help`:
359
+ // the feature worked and nothing a stranger would read mentioned it.
360
+ /**
361
+ * ⚠️ A BLANK LINE BEFORE IT, not just after. Roman: *"move it one line down."*
362
+ * Pressed against the banner it read as a fifth detail row rather than as an
363
+ * instruction addressed to the person.
364
+ */
365
+ output.write('\nType what you want done. "/help" for commands, "exit" to leave.\n\n');
366
+
367
+ let history = null;
368
+ let turns = 0;
369
+ /**
370
+ * SET BY `/skills <name>`, CONSUMED BY THE NEXT REAL TURN AND THEN CLEARED.
371
+ * A skill that was printed to the terminal would look loaded and be invisible
372
+ * to the model; this is the variable that makes the verb real.
373
+ */
374
+ let pendingInject = null;
375
+ /** Lines the user has submitted this session — the box's Up/Down history. */
376
+ const typed = [];
377
+
378
+ /**
379
+ * ── ⭐⭐⭐ THE BOX IS PINNED TO THE BOTTOM OF THE SCREEN ────────────────────
380
+ *
381
+ * Roman: *"we need that prompt box stuck down the bottom, it is professional."*
382
+ *
383
+ * `pinRegion` reserves the last three rows, so output scrolls ABOVE the box
384
+ * and the box itself never moves. Off a TTY, in CI, on a short terminal, or
385
+ * with ACUVO_NO_PIN=1 it does nothing at all.
386
+ *
387
+ * ⚠️⚠️ RELEASED ON EVERY PATH OUT, INCLUDING THE ONES NOBODY PLANS FOR. A
388
+ * process that exits with a scroll region still set leaves the user with a
389
+ * terminal that scrolls inside a box until they type `reset` blind — the same
390
+ * class of harm as leaving raw mode on. The `finally` covers a normal end and
391
+ * a throw; the signal handlers cover Ctrl-C and `kill`.
392
+ */
393
+ const pin = interactive ? pinRegion(output) : { enabled: false, release() {} };
394
+ const releasePin = () => pin.release();
395
+ if (pin.enabled) {
396
+ process.once('exit', releasePin);
397
+ process.once('SIGINT', releasePin);
398
+ process.once('SIGTERM', releasePin);
399
+ }
400
+
401
+ try {
402
+ for (;;) {
403
+ /**
404
+ * ── ⭐⭐ THE INPUT IS A BOX, BECAUSE A BARE `› ` IS NOT A PLACE TO TYPE ──
405
+ *
406
+ * Roman, comparing against a real Claude Code screenshot: *"you can see
407
+ * the box where you type, acuvo doesn't have that."* He is right — the
408
+ * prompt was two characters floating in the scrollback, which reads as
409
+ * output rather than as somewhere input goes.
410
+ *
411
+ * ⚠️ THE BOX OPENS BEFORE THE LINE AND CLOSES AFTER IT, deliberately.
412
+ * Drawing all four sides up front needs absolute cursor control, and
413
+ * readline owns the cursor once `question()` starts every version of
414
+ * that fights the line editor the moment input wraps, a history entry is
415
+ * recalled, or the window is resized. Opening on entry and closing on
416
+ * submit needs no cursor math at all, survives all three, and leaves a
417
+ * transcript where each turn is visibly a closed unit.
418
+ *
419
+ * ⚠️ WIDTH IS READ FRESH EACH TURN a terminal resized mid-session would
420
+ * otherwise draw rules at the old width for the rest of the run. Clamped,
421
+ * because `columns` is `undefined` when stdout is not a TTY and enormous
422
+ * when someone maximises on an ultrawide.
423
+ */
424
+ let line;
425
+ if (interactive) {
426
+ output.write('\n');
427
+ const got = await readBoxedLine({
428
+ input,
429
+ output,
430
+ history: typed,
431
+ onInterrupt,
432
+ });
433
+ line = got.value;
434
+ } else {
435
+ line = queueIndex < queued.length ? queued[queueIndex++] : null;
436
+ }
437
+ // Echo a piped prompt so a scripted transcript reads like a session.
438
+ if (!interactive && line !== null && line.trim()) output.write(`› ${line.trim()}
439
+ `);
440
+ // ⚠️ EOF is not an error. A closed pipe or Ctrl-D ends the session the
441
+ // same way "exit" does treating it as a fault would print a stack trace
442
+ // at the end of every scripted run.
443
+ if (line === null) break;
444
+ const task = line.trim();
445
+ if (!task) continue;
446
+ // ⚠️ Deduped against the PREVIOUS entry only: pressing Up should walk
447
+ // distinct instructions, not scroll through five copies of `npm test`.
448
+ if (typed[typed.length - 1] !== task) typed.push(task);
449
+ if (QUIT.has(task.toLowerCase())) break;
450
+
451
+ /**
452
+ * ── THE `/` SURFACE, BEFORE ANYTHING IS SENT TO A MODEL ────────────
453
+ *
454
+ * ⚠️ IT COSTS NOTHING AND MUST NOT COUNT AS A TURN. `/cost` is asked
455
+ * precisely by somebody watching their spend, and answering it by
456
+ * incrementing the turn counter and appending to the history would make
457
+ * the question change the answer.
458
+ *
459
+ * ⚠️ AN UNRECOGNISED COMMAND IS ANSWERED HERE AND NOT FORWARDED. Passing
460
+ * `/skil` to the model gets a confident essay about a typo; the one thing
461
+ * the person needed was the word `/skills`, which `slash.mjs` supplies.
462
+ * See its header for why `/etc/hosts` is NOT treated as a command.
463
+ */
464
+ const command = parseSlash(line.trim());
465
+ if (command) {
466
+ const result = runSlashCommand(command, slashContext);
467
+ for (const l of result.output ?? []) output.write(`${l}\n`);
468
+ output.write('\n');
469
+ if (result.effect === 'clear') history = null;
470
+ if (typeof result.inject === 'string' && result.inject) pendingInject = result.inject;
471
+ continue;
472
+ }
473
+
474
+ /**
475
+ * ⚠️ THE SKILL IS PREPENDED TO THE TASK, NOT SUBSTITUTED FOR IT. The user
476
+ * typed an instruction; the skill is context for it. And it is cleared
477
+ * BEFORE the call rather than after, so a turn that throws cannot leave it
478
+ * armed and silently attach it to an unrelated question later.
479
+ */
480
+ let sendTask = task;
481
+ if (pendingInject) {
482
+ sendTask = `${pendingInject}\n\n---\n\n${task}`;
483
+ pendingInject = null;
484
+ }
485
+
486
+ let outcome;
487
+ try {
488
+ outcome = await runOne(sendTask, history);
489
+ } catch (err) {
490
+ /**
491
+ * ⚠️ ONE BAD TURN MUST NOT END THE SESSION. A timeout or a provider blip
492
+ * after twenty minutes of context is infuriating if it drops everything;
493
+ * the history is still valid, so report and keep the prompt.
494
+ */
495
+ output.write(`\n ✖ that turn failed: ${String(err?.message || err)}\n\n`);
496
+ continue;
497
+ }
498
+
499
+ turns += 1;
500
+ render(outcome, output);
501
+
502
+ if (outcome?.ok && Array.isArray(outcome.messages)) {
503
+ history = trimHistory(outcome.messages, maxHistory);
504
+ } else if (!outcome?.ok) {
505
+ // A failed turn leaves history UNTOUCHED. Appending a turn that produced
506
+ // nothing would poison the next one with a dead exchange.
507
+ output.write(`\n (history unchanged — that turn did not complete)\n`);
508
+ }
509
+ output.write('\n');
510
+ }
511
+ } finally {
512
+ if (rl) rl.close();
513
+ pin.release();
514
+ // ⚠️ The listeners come off too — a long-lived process that started several
515
+ // sessions would otherwise accumulate them and warn about a leak.
516
+ process.off('exit', releasePin);
517
+ process.off('SIGINT', releasePin);
518
+ process.off('SIGTERM', releasePin);
519
+ }
520
+ return { turns };
521
+ }
package/lib/input-box.mjs CHANGED
@@ -349,3 +349,61 @@ export function readBoxedLine({ input, output, history = [], onInterrupt = null,
349
349
  input.once('end', onEnd);
350
350
  });
351
351
  }
352
+
353
+ /**
354
+ * ── ⭐⭐⭐ PINNING THE BOX TO THE BOTTOM OF THE SCREEN ───────────────────────
355
+ *
356
+ * Roman: *"we need that prompt box stuck down the bottom, it is professional."*
357
+ *
358
+ * A terminal can be told to scroll only PART of itself. `ESC[{top};{bottom}r`
359
+ * sets the scrolling region; everything printed scrolls inside it, and the rows
360
+ * below are left alone. Reserve the last three and the box never moves while
361
+ * output flows past above it.
362
+ *
363
+ * ── ⚠️⚠️ THE PART THAT MUST NEVER BE GOT WRONG ──────────────────────────────
364
+ *
365
+ * A process that exits WITHOUT releasing the region leaves the user with a
366
+ * terminal that scrolls inside a box forever, fixable only by typing `reset`
367
+ * blind. That is the same class of harm as leaving raw mode on, and it must be
368
+ * released on every path out — normal exit, Ctrl-C, SIGTERM, and an uncaught
369
+ * throw. `release()` is idempotent and safe to call from all of them.
370
+ *
371
+ * ⚠️ AND IT IS OPT-IN. A reserved region is a claim on somebody's whole screen;
372
+ * off a TTY, in CI, under a pipe or with ACUVO_NO_PIN=1 it is never set.
373
+ */
374
+ export function pinRegion(output, { rows = 3, env = process.env } = {}) {
375
+ const height = output?.rows ?? process.stdout?.rows ?? 0;
376
+ const enabled = Boolean(output?.isTTY)
377
+ && height > rows + 4
378
+ && String(env.ACUVO_NO_PIN ?? '') !== '1'
379
+ && String(env.CI ?? '').toLowerCase() !== 'true';
380
+
381
+ if (!enabled) return { enabled: false, release() {}, rows: 0, bottom: 0 };
382
+
383
+ const bottom = height - rows;
384
+ let released = false;
385
+
386
+ /**
387
+ * ⚠️ THE CURSOR IS PARKED INSIDE THE SCROLL REGION BEFORE ANYTHING PRINTS.
388
+ * Setting a region moves the cursor to home (1,1) on most terminals, so
389
+ * without this the next line of output lands at the TOP of the screen and the
390
+ * transcript reads backwards.
391
+ */
392
+ output.write(`${CSI}1;${bottom}r${CSI}${bottom};1H`);
393
+
394
+ const release = () => {
395
+ if (released) return;
396
+ released = true;
397
+ /**
398
+ * ⚠️ `ESC[r` WITH NO ARGUMENTS RESETS TO THE FULL SCREEN. Then the cursor is
399
+ * moved below the reserved rows so the shell prompt does not land on top of
400
+ * our box — an exit that leaves the terminal technically correct and
401
+ * visually broken is still a bad exit.
402
+ */
403
+ try {
404
+ output.write(`${CSI}r${CSI}${height};1H\n`);
405
+ } catch { /* the stream may already be gone on a hard exit */ }
406
+ };
407
+
408
+ return { enabled: true, release, rows, bottom };
409
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acuvo-code",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "Acuvo Code — the terminal client for the Acuvo capability registry. Zero dependencies, by design.",
5
5
  "type": "module",
6
6
  "bin": {