acuvo-code 0.6.2 → 0.6.4

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
@@ -188,7 +188,7 @@ copy, but it *is* a place a process starts, and this is a list of those. Six and
188
188
  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
- one went stale in turn.** The package ships **120 files — 118 in `lib/`, 2 in
191
+ one went stale in turn.** The package ships **121 files — 119 in `lib/`, 2 in
192
192
  `bin/` — about 78511 lines**, with **238 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
@@ -892,7 +892,7 @@ For completeness, the properties none of them offers:
892
892
  (`lib/media.mjs`), and generates imagery with no configuration and no account
893
893
  (`lib/imagegen.mjs`) — critiqued before it is accepted, and reported as unreviewed when
894
894
  no critic is available.
895
- - ⭐ **Zero dependencies.** The entire auditable surface is 120 files and 78511 lines,
895
+ - ⭐ **Zero dependencies.** The entire auditable surface is 121 files and 79376 lines,
896
896
  and there is no `node_modules` behind it. (Counted 2026-08-22 from
897
897
  `lib/*.mjs` + `bin/*.mjs`; `test/docs-truth.test.mjs` fails the build if this number
898
898
  drifts, which is why it went 18 → 41 → 46 → 52 → 53 → 57 → 60 → 61 → 62 → 65 → 66 → 69 → 70 → 71 → 72 → 73 → 80 → 84 → 90 → 100 → 101 → 102 → 103 → 107 → 108 → 111 as modules landed (111 = the three that were WRITTEN and imported by nothing — `python.mjs`, `cache-floor.mjs`, `plan-coherence.mjs`; 108 = `warm-provider.mjs`, which keeps a session on the upstream that holds its prompt cache; 107 = `login.mjs`, the command that stores an Acuvo credential — until it existed, `writeAccount` was called by nothing and every user fell through to BYOK). ⚠️ Two of those three landed on this count while remaining UNREACHABLE, which is the sharpest illustration this document has that a file count is a claim about bytes, never about capability. ⭐ A
package/bin/acuvo.mjs CHANGED
@@ -61,7 +61,7 @@ import { detectRepo, findToken, fetchIssue, branchNameFor, issueToTask, createBr
61
61
  // `formatSummary`'s job, and importing it here is how the second copy came back.
62
62
  import { describeChanges, shortenRoot, toJson } from '../lib/report.mjs';
63
63
  import { renderImage } from '../lib/terminal-graphics.mjs';
64
- import { saveSession, listSessions, resumeMessages, loadSession } from '../lib/session.mjs';
64
+ import { saveSession, listSessions, resumeMessages, loadSession, newSessionId, findCrashedSession, crashOfferLines, markSessionClosed } from '../lib/session.mjs';
65
65
  import { recordRun, parseAuditLog } from '../lib/audit.mjs';
66
66
  import { runBestOf, formatBestOf } from '../lib/best-of.mjs';
67
67
  import { escalate, formatEscalation, outOfRoad } from '../lib/escalate.mjs';
@@ -1489,6 +1489,28 @@ ${formatBoard(listed)}
1489
1489
  * chat loop owns the invitation, and neither should own both.
1490
1490
  */
1491
1491
  const { openingScreen } = await import('../lib/banner.mjs');
1492
+ /**
1493
+ * ── ⭐⭐⭐ ASK THE TERMINAL, DO NOT ASSUME IT ────────────────────────────────
1494
+ *
1495
+ * The half-block mark is only correct if this terminal renders ▀ ▄ █ at ONE
1496
+ * cell. They are East-Asian-Ambiguous, so that is a property of the font and
1497
+ * the emulator, not of the character — and where they come out double-width
1498
+ * the mark TEARS: the padding spaces stay narrow while the blocks do not, so
1499
+ * every row shifts by a different amount and the right-hand column is pushed
1500
+ * off the screen.
1501
+ *
1502
+ * ⚠️ THIS IS THE FOURTH ATTEMPT AT THIS SCREEN AND THE FIRST THAT MEASURES.
1503
+ * The previous three reasoned from byte sequences that were correct on my
1504
+ * machine and wrong on Roman's, which is the only machine that counts.
1505
+ * `measureCellWidth` prints the glyph and asks the terminal where the cursor
1506
+ * landed — not an inference about fonts, the terminal describing itself.
1507
+ * Unknown falls back to the ASCII mark: a plainer logo on a capable terminal
1508
+ * costs a little beauty, torn blocks on an incapable one cost a first
1509
+ * impression.
1510
+ */
1511
+ const { measureCellWidth, bannerStyle } = await import('../lib/glyph-width.mjs');
1512
+ const cellWidth = await measureCellWidth({ input: process.stdin, output: process.stdout });
1513
+ const bannerLook = bannerStyle({ cellWidth, env: process.env });
1492
1514
  /**
1493
1515
  * ── ⭐⭐ THE BRAND NAME, NOT THE VENDOR'S ────────────────────────────────────
1494
1516
  *
@@ -1518,6 +1540,14 @@ ${formatBoard(listed)}
1518
1540
  * site.
1519
1541
  */
1520
1542
  paint: createPainter(colourEnabled()),
1543
+ style: bannerLook,
1544
+ /**
1545
+ * ⚠️ THE REAL WIDTH, NOT A CONSTANT. The banner used to build to a fixed
1546
+ * 80 columns and never ask, so in any narrower terminal — a split pane, a
1547
+ * side panel — every row wrapped. `columns` is undefined off a TTY, which
1548
+ * the banner reads as "assume 80": right for a pipe, wrong for nothing.
1549
+ */
1550
+ columns: process.stdout.columns ?? null,
1521
1551
  });
1522
1552
  if (opts.json) process.stderr.write(banner);
1523
1553
  else process.stdout.write(banner);
@@ -1612,6 +1642,84 @@ ${formatBoard(listed)}
1612
1642
  process.on('exit', () => { try { if (claimed?.lease) releaseAll([claimed.lease]); } catch { /* exiting anyway */ } });
1613
1643
  }
1614
1644
 
1645
+ /**
1646
+ * ── ⭐⭐⭐ "IF MY LAPTOP CRASHES, THE CHAT HISTORY IS GONE" ─────────────────
1647
+ *
1648
+ * ⚠️ IT WAS. MEASURED 2026-08-22: a run killed with SIGKILL after two
1649
+ * completed rounds and two files written left `.acuvo/sessions/` NON-EXISTENT.
1650
+ * `saveSession` only ever ran after the loop, so the run you most want back
1651
+ * was the only kind that left nothing behind. The checkpoint wiring in
1652
+ * `oneTurn` fixed the WRITE half; this is the READ half, and without it the
1653
+ * recovery only exists for someone who already knows to type `--continue` —
1654
+ * which is not the person who just lost their work.
1655
+ *
1656
+ * ⚠️⚠️ IT MUST NEVER FIRE FOR A RUN THAT IS STILL GOING. Seven terminals in
1657
+ * one workspace is the documented normal case for this tool, and each one
1658
+ * holds an open live record. `findCrashedSession` refuses any record whose pid
1659
+ * still answers — see its header for the other two conditions.
1660
+ *
1661
+ * ⭐ ACCEPTING IT REUSES `--resume` WHOLE. The offer sets an id and the
1662
+ * existing block below does the rest, so the restored conversation, the sticky
1663
+ * routing key and the budget subtraction are the same code on both doors. A
1664
+ * second copy of that block is how one of the two would end up without the
1665
+ * budget guard.
1666
+ *
1667
+ * ⚠️ THE PROMPT IS TTY-ONLY. A CI job, a `| jq` pipeline or a cron entry must
1668
+ * never block on a question nobody is there to answer — those get the lines
1669
+ * and the command, and carry on with the fresh run they asked for.
1670
+ */
1671
+ let crashOfferId = null;
1672
+ /**
1673
+ * ⚠️ `opts.bestOf < 2` FOR THE SAME REASON `--best-of` REFUSES `--resume`
1674
+ * outright: it forks the task into independent attempts and keeps one, so
1675
+ * accepting a restored conversation here would silently discard the history
1676
+ * the user had just said yes to. Offering something we would then throw away
1677
+ * is worse than not offering.
1678
+ */
1679
+ if (!resumeRequested && !opts.parallel && opts.issue === null && !opts.dryRun && life.save && opts.bestOf < 2) {
1680
+ let found = { ok: false };
1681
+ try { found = findCrashedSession(root); } catch { /* a recovery hint may never break a run */ }
1682
+ if (found.ok && found.crashed) {
1683
+ const say = (t) => (opts.json ? process.stderr : process.stdout).write(t);
1684
+ say(`${crashOfferLines(found.crashed).join('\n')}\n`);
1685
+ const askable = process.stdin.isTTY === true && process.stdout.isTTY === true && !opts.json;
1686
+ if (askable) {
1687
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
1688
+ const answer = await new Promise((r) => rl.question(' continue that conversation instead of starting fresh? [Y/n] ', (l) => { rl.close(); r(l); }));
1689
+ /**
1690
+ * ⚠️ ENTER MEANS YES HERE, WHICH IS THE OPPOSITE OF `--task-audio`'s
1691
+ * default, and the difference is deliberate: that prompt guards an
1692
+ * ACTION taken on a possibly mis-heard instruction, so silence must
1693
+ * cancel. This one guards CONTEXT the user already paid for, and
1694
+ * nothing is executed by restoring it — the expensive mistake is
1695
+ * throwing the conversation away, not keeping it.
1696
+ */
1697
+ if (!/^\s*n(o)?\s*$/i.test(answer)) crashOfferId = found.crashed.id;
1698
+ else say(' starting fresh. That run stays on disk — `acuvo --sessions` lists it.\n');
1699
+ }
1700
+ /**
1701
+ * ⚠️⚠️ ANSWERED IS ANSWERED — INCLUDING "I ONLY PRINTED IT". The marker
1702
+ * lives in the record, so without this line the same warning fires on
1703
+ * every subsequent run in this workspace, for ever, and a warning that
1704
+ * fires when nothing is wrong is one people learn to read past. That would
1705
+ * cost the real one.
1706
+ *
1707
+ * ⚠️ NOT ON ACCEPT, AND THE ORDER IS THE REASON. `resumeMessages` reads
1708
+ * `closedCleanly` to tell the model "that run was KILLED mid-round, its
1709
+ * last round may be missing but its work may be on disk". Closing the
1710
+ * record here would erase that sentence a few lines before it is written.
1711
+ * The resume block below marks it once it has been read.
1712
+ *
1713
+ * ⭐ NOTHING IS DELETED EITHER WAY. The record stays listable, replayable
1714
+ * and `--resume <id>`-able; it just stops volunteering — exactly what the
1715
+ * decline message above promises.
1716
+ */
1717
+ if (!crashOfferId) {
1718
+ try { markSessionClosed(root, found.crashed.id); } catch { /* the offer already did its job */ }
1719
+ }
1720
+ }
1721
+ }
1722
+
1615
1723
  let priorMessages = null;
1616
1724
  /**
1617
1725
  * ── ⭐⭐⭐ ONE STICKY KEY FOR THIS WHOLE CONVERSATION, ACROSS PROCESSES ────
@@ -1629,7 +1737,7 @@ ${formatBoard(listed)}
1629
1737
  * the conversation being resumed had already paid to build.
1630
1738
  */
1631
1739
  let stickyKey = `acuvo-${randomUUID()}`;
1632
- if (resumeRequested) {
1740
+ if (resumeRequested || crashOfferId) {
1633
1741
  if (life.resume !== null && life.continueLatest) {
1634
1742
  die('--resume <id> and --continue both name a run to carry on, and they disagree. Pass one: --continue takes the most recent, --resume takes the id you name.', EXIT_USAGE);
1635
1743
  }
@@ -1640,7 +1748,10 @@ ${formatBoard(listed)}
1640
1748
  die('--issue starts a fresh branch and a fresh conversation, so there is nothing to resume. Drop one of --issue / --resume.', EXIT_USAGE);
1641
1749
  }
1642
1750
 
1643
- let id = life.resume;
1751
+ // ⚠️ `crashOfferId` is only ever set when NEITHER flag was given (the guard
1752
+ // above requires `!resumeRequested`), so this cannot silently outrank a
1753
+ // `--resume <id>` the user typed.
1754
+ let id = life.resume ?? crashOfferId;
1644
1755
  if (life.continueLatest) {
1645
1756
  const listed = listSessions(root, { limit: 50 });
1646
1757
  if (!listed.ok) die(listed.error, EXIT_FAILED);
@@ -1663,6 +1774,16 @@ ${formatBoard(listed)}
1663
1774
  // ⭐ The saved id IS the conversation, so it is the routing key too. This
1664
1775
  // line is what makes stickiness survive closing the terminal.
1665
1776
  stickyKey = `acuvo-${resumed.id ?? id}`;
1777
+ /**
1778
+ * ⚠️ AFTER `resumeMessages`, NEVER BEFORE — it reads the crash marker to
1779
+ * tell the model that run was killed mid-round. Carrying the conversation
1780
+ * forward is what closes the book on the old record: this run now owns the
1781
+ * history, so the old one must stop announcing itself as unfinished on every
1782
+ * future `acuvo` in this workspace.
1783
+ */
1784
+ if (resumed.crashed) {
1785
+ try { markSessionClosed(root, resumed.id ?? id); } catch { /* the resume already succeeded */ }
1786
+ }
1666
1787
  if (!task) task = resumed.task;
1667
1788
  if (!task) {
1668
1789
  die(`run ${resumed.id} recorded no task text, so "carry on" has nothing to carry. Say what to do next: acuvo --resume ${resumed.id} "<the next step>"`, EXIT_USAGE);
@@ -1672,6 +1793,24 @@ ${formatBoard(listed)}
1672
1793
  (opts.json ? process.stderr : process.stdout).write(
1673
1794
  ` · resuming ${resumed.id} — ${priorMessages.length} messages restored, nothing re-run${warn}\n`,
1674
1795
  );
1796
+ /**
1797
+ * ── ⚠️ SAY IT WHEN THE DISCOUNT IS GONE ──────────────────────────────────
1798
+ *
1799
+ * The first two messages ARE the cacheable prefix. A record whose head was
1800
+ * clipped resumes into a prompt that differs from the original at message
1801
+ * ZERO, so prefix caching misses on every token — measured at 60.8%
1802
+ * byte-identical before `MAX_HEAD_CHARS` gave the head its own ceiling, i.e.
1803
+ * the whole restored conversation re-bought at full price.
1804
+ *
1805
+ * ⚠️ It is silent on every ordinary resume, which is what makes it worth
1806
+ * printing at all: this fires only on a head above 60,000 characters, and
1807
+ * somebody seeing it needs to know the resume is honest but not cheap.
1808
+ */
1809
+ if (resumed.headTruncated) {
1810
+ (opts.json ? process.stderr : process.stdout).write(
1811
+ ' ⚠ that record\'s opening message was too large to store whole, so the prompt cache cannot hit on this resume — expect it to cost like a fresh run.\n',
1812
+ );
1813
+ }
1675
1814
 
1676
1815
  /**
1677
1816
  * ── ⚠️⚠️ A RESUMED RUN USED TO GET A WHOLE FRESH BUDGET ──────────────────
@@ -1845,6 +1984,22 @@ ${formatBoard(listed)}
1845
1984
  },
1846
1985
  });
1847
1986
 
1987
+ /**
1988
+ * ── ⭐⭐⭐ THE ID THIS TURN WILL BE SAVED UNDER, DECIDED BEFORE IT STARTS ──
1989
+ *
1990
+ * ⚠️ MEASURED, WHICH IS WHY IT IS HERE: a run SIGKILLed mid-round left NO
1991
+ * `.acuvo/sessions/` directory at all — every save happened after the loop,
1992
+ * so the run whose conversation you would most want back was the only kind
1993
+ * that left none. The checkpoints below write the same record the end of the
1994
+ * turn writes, under this id, marked `live` until the turn closes it.
1995
+ *
1996
+ * ⚠️ `--dry-run` AND `--no-session` MUST BE HONOURED HERE TOO, not only in
1997
+ * `persistRun`. A dry run that scattered eight session files while promising
1998
+ * to "touch nothing" is the same broken promise, arrived at from a new door.
1999
+ */
2000
+ const turnSessionId = newSessionId();
2001
+ const checkpointing = life.save && !opts.dryRun && !over.quiet;
2002
+
1848
2003
  let result;
1849
2004
  try {
1850
2005
  result = await runSession({
@@ -1947,6 +2102,26 @@ ${formatBoard(listed)}
1947
2102
  untilDone: opts.untilDone,
1948
2103
  // ⭐ The admin layer reaches the loop. OPEN_POLICY when no file exists.
1949
2104
  policy,
2105
+ /**
2106
+ * ── ⭐⭐⭐ THE WIRE THAT MAKES A KILLED RUN RECOVERABLE ──────────────────
2107
+ *
2108
+ * `runSession` calls this at every round boundary with the same shape it
2109
+ * returns at the end, so `saveSession` is the whole implementation —
2110
+ * there is one definition of a session record, not a live one and a final
2111
+ * one that drift apart the first time either grows a field.
2112
+ *
2113
+ * ⚠️ IT SWALLOWS ITS OWN FAILURES ON PURPOSE, and this is the one place in
2114
+ * this file that does so silently. `persistRun` announces a failed save
2115
+ * because that is the last word on a finished run; announcing a failed
2116
+ * CHECKPOINT would print the same line once per round for the rest of a
2117
+ * long run, which is how people learn to read past the line that matters.
2118
+ * The end-of-turn save hits the same disk and will say so.
2119
+ */
2120
+ onCheckpoint: checkpointing
2121
+ ? (partial) => {
2122
+ try { saveSession(root, partial, { task: turnTask, id: turnSessionId, live: true }); } catch { /* the work outranks the record */ }
2123
+ }
2124
+ : null,
1950
2125
  // ⚠️ STREAMED, NOT BUFFERED. A bounded loop that prints only at the end is
1951
2126
  // indistinguishable from a hang for however long it takes, and the whole
1952
2127
  // value of watching a fix land is watching it land.
@@ -2080,7 +2255,13 @@ ${formatBoard(listed)}
2080
2255
  */
2081
2256
  gate.dispose();
2082
2257
  }
2083
- persistRun(turnTask, result);
2258
+ /**
2259
+ * ⚠️ THE SAME ID THE CHECKPOINTS USED — this call is what flips
2260
+ * `closedCleanly` to true. Passing a fresh id here would leave the live
2261
+ * record open forever, and the next `acuvo` in this workspace would offer to
2262
+ * recover a run that finished perfectly well.
2263
+ */
2264
+ persistRun(turnTask, result, turnSessionId);
2084
2265
  /**
2085
2266
  * ── ⭐ `--say` — NARRATE THE VERDICT ──────────────────────────────────────
2086
2267
  *
@@ -2216,11 +2397,26 @@ ${formatBoard(listed)}
2216
2397
  * dry run that creates two files in the workspace has broken that promise
2217
2398
  * to save a record of a run that did not happen.
2218
2399
  */
2219
- const persistRun = (turnTask, result) => {
2400
+ /**
2401
+ * ── ⭐⭐⭐ ONE ID PER TURN, SHARED BY THE LIVE SAVES AND THE FINAL ONE ──────
2402
+ *
2403
+ * ⚠️ WITHOUT THIS THE CRASH RECOVERY WOULD LITTER. `saveSession` mints a fresh
2404
+ * id whenever it is not given one, so checkpointing each round would leave
2405
+ * eight files for one turn and the finished record would be a ninth — and the
2406
+ * eight orphans would all still carry `closedCleanly: false`, so every one of
2407
+ * them would look like a crash to the startup check. Handing the same id to
2408
+ * every write means one file per turn, exactly as before, whose only new
2409
+ * content is a boolean that flips true when the turn ends.
2410
+ *
2411
+ * ⚠️ MINTED PER TURN, NOT PER PROCESS: interactive mode runs many turns and
2412
+ * has always written one record each. Sharing one id across a conversation
2413
+ * would collapse the whole history into a single overwritten file.
2414
+ */
2415
+ const persistRun = (turnTask, result, id = null) => {
2220
2416
  if (opts.dryRun) return;
2221
2417
  if (life.save) {
2222
2418
  try {
2223
- const saved = saveSession(root, result, { task: turnTask });
2419
+ const saved = saveSession(root, result, { task: turnTask, ...(id ? { id } : {}) });
2224
2420
  if (!saved.ok) process.stderr.write(` · the run was not saved: ${saved.error}\n`);
2225
2421
  } catch (e) {
2226
2422
  process.stderr.write(` · the run was not saved: ${e?.message ?? e}\n`);
package/lib/banner.mjs CHANGED
@@ -1,155 +1,255 @@
1
- /**
2
- * ── ⭐⭐⭐ WHAT YOU SEE WHEN YOU TYPE `acuvo` ────────────────────────────────
3
- *
4
- * Roman, 2026-08-22, having typed it: *"it's not opening as a typable terminal,
5
- * with acuvo logo top left and details up top etc, like how claude code opens.
6
- * have you actually designed the page?"* — and then: *"no we want OUR logo"*.
7
- *
8
- * So this is the real mark, not letters spelling the name. It is
9
- * `console/public/brand/acuvo-mark.png` — the angular A — resampled to
10
- * half-blocks at 14x14 and embedded as text.
11
- *
12
- * ── ⚠️ WHY IT IS EMBEDDED RATHER THAN DECODED AT RUNTIME ────────────────────
13
- *
14
- * This package has ZERO dependencies, deliberately, and Node cannot decode a
15
- * PNG without one. Converting at build time and pasting the result keeps that
16
- * promise and costs nothing at startup. If the mark ever changes, re-run the
17
- * conversion — the logo is DERIVED from the brand asset, not drawn by hand, so
18
- * it stays honest to it.
19
- *
20
- * ── ⚠️ THE CONSTRAINTS, WHICH ARE NOT PREFERENCES ───────────────────────────
21
- *
22
- * · **Half-block glyphs only** (▀ ▄ █). Braille and box-drawing render as tofu
23
- * in cmd.exe and in many CI log viewers, and a logo that renders as question
24
- * marks is worse than no logo — on the exact platform this is developed on.
25
- * · **No colour escapes here.** The caller owns colour and honours NO_COLOR; a
26
- * module that hard-codes them emits garbage the moment output is piped.
27
- * · **Every line under 80 columns.** The narrowest terminal in real use is 80,
28
- * and a wrapped banner does not read as dense, it reads as broken.
29
- *
30
- * ⭐ A NOTE ON DOING BETTER: `lib/terminal-graphics.mjs` can send a real PNG
31
- * inline on Kitty and iTerm2. It is deliberately NOT used here — Windows
32
- * Terminal speaks neither protocol, so the block art is what most users would
33
- * see anyway, and one rendering that is the same everywhere beats two that
34
- * disagree.
35
- */
36
-
37
- /**
38
- * The Acuvo mark, resampled from the brand PNG. Seven rows: tall enough to be
39
- * the mark rather than a smudge, short enough for something run forty times a
40
- * day rather than opened once.
41
- */
42
- const MARK = [
43
- ' ▄█',
44
- ' ▄███',
45
- ' ▄█▀▀██',
46
- ' ▄█▀ ▀██',
47
- ' ▄█▀ ██ ▀██',
48
- ' ▄████▀▀█████',
49
- '█▀ █▀ ▀█',
50
- ];
51
-
52
- const MARK_WIDTH = Math.max(...MARK.map((l) => l.length));
53
- const GUTTER = 2;
54
- export const MAX_BANNER_COLUMNS = 80;
55
-
56
- /** Columns left for the detail rows once the mark and gutter are placed. */
57
- const TEXT_COLUMNS = MAX_BANNER_COLUMNS - MARK_WIDTH - GUTTER;
58
-
59
- /**
60
- * Build the opening screen: mark on the left, facts on the right.
61
- *
62
- * @param {object} o
63
- * @param {string} o.version
64
- * @param {string} o.workspace already shortened by the caller
65
- * @param {string} o.model
66
- * @param {string} o.billing who this run will charge
67
- * @param {string} o.canRun what it may execute, or that it may not
68
- * @param {boolean} [o.interactive] whether a prompt follows
69
- * @returns {string}
70
- */
71
- export function openingScreen({ version, workspace, model, billing, canRun, interactive = false, paint = null }) {
72
- /**
73
- * ── ⭐ THE MARK IS BRAND GREEN, AND ONLY THE MARK ────────────────────────────
74
- *
75
- * Roman: *"can you colour our logo green in the terminal?"* #C8E91E, sampled
76
- * from the brand PNG rather than picked by eye.
77
- *
78
- * ⚠️ THE PAINTER IS INJECTED, NOT IMPORTED. The caller already decided whether
79
- * this stream can take colour — it owns NO_COLOR, FORCE_COLOR, TERM=dumb and
80
- * TTY detection. A module that reaches for colour itself will eventually
81
- * disagree with that decision and write escapes into somebody's redirected
82
- * file.
83
- *
84
- * ⚠️ AND ONLY THE LOGO. Colouring the detail rows would make the one line that
85
- * must be read as a warning — `billing: YOUR OWN OpenRouter key` — compete
86
- * with decoration.
87
- */
88
- const brand = paint?.brand ?? ((s) => s);
89
- /**
90
- * ── ⚠️⚠️ ELIDED, BECAUSE THE VALUES ARE NOT OURS ────────────────────────────
91
- *
92
- * Every value arrives at runtime: a deep monorepo path, a long model id, the
93
- * shell-mode warning. The first version assumed they would be short and its
94
- * own test caught it wrapping at 80 columns on ordinary inputs.
95
- *
96
- * ELIDED IN THE MIDDLE, NOT THE END. The informative parts of a path are
97
- * the drive and the leaf; chopping the tail leaves
98
- * `C:\Users\somebody\Projects\a-`, which identifies nothing. Same for a model
99
- * id, where the family is at the front and the variant at the back.
100
- */
101
- const room = TEXT_COLUMNS - 11;
102
- const fit = (v) => {
103
- const s = String(v ?? '');
104
- if (s.length <= room) return s;
105
- const head = Math.ceil((room - 1) / 2);
106
- return `${s.slice(0, head)}…${s.slice(s.length - (room - 1 - head))}`;
107
- };
108
-
109
- const right = [
110
- `ACUVO CODE${version ? ` ${version}` : ''}`,
111
- '',
112
- `workspace ${fit(workspace)}`,
113
- `model ${fit(model)}`,
114
- `billing ${fit(billing)}`,
115
- `can run ${fit(canRun)}`,
116
- '',
117
- ];
118
-
119
- /**
120
- * ⚠️ THE TWO COLUMNS ARE ZIPPED, NOT CONCATENATED, and the row counts are
121
- * allowed to differ whichever is shorter simply runs out. Assuming they
122
- * match would break the layout the first time a row is added to either side.
123
- */
124
- const rows = Math.max(MARK.length, right.length);
125
- const lines = [''];
126
- for (let i = 0; i < rows; i += 1) {
127
- const text = right[i] ?? '';
128
- /**
129
- * ⚠️ PADDED FIRST, PAINTED SECOND — escape codes have no width, so padding a
130
- * coloured string aligns the text against invisible bytes and the whole
131
- * right-hand column drifts.
132
- *
133
- * ⚠️⚠️ AND PADDED ONLY WHEN SOMETHING FOLLOWS. On a mark-only row the
134
- * padding sits INSIDE the colour, before the reset, where the `trimEnd`
135
- * below cannot reach it so the coloured banner carried trailing
136
- * whitespace the plain one did not. Invisible, but it means colour changed
137
- * the layout, which is exactly what the guard forbids.
138
- */
139
- const rawMark = MARK[i] ?? '';
140
- const padded = text ? rawMark.padEnd(MARK_WIDTH + GUTTER) : rawMark;
141
- const mark = MARK[i] ? brand(padded) : padded;
142
- lines.push(`${mark}${text}`.trimEnd());
143
- }
144
- lines.push('');
145
-
146
- /**
147
- * ⚠️ ONLY WHEN A PROMPT ACTUALLY FOLLOWS. Printing "type what you want done"
148
- * above a one-shot run that has already been given its task is an instruction
149
- * for something the user cannot do, on the screen of a thing already working.
150
- */
151
- if (interactive) {
152
- lines.push(' Type what you want done. /help for commands · exit to leave', '');
153
- }
154
- return lines.join('\n');
155
- }
1
+ /**
2
+ * ── ⭐⭐⭐ WHAT YOU SEE WHEN YOU TYPE `acuvo` ────────────────────────────────
3
+ *
4
+ * Roman, 2026-08-22, having typed it: *"it's not opening as a typable terminal,
5
+ * with acuvo logo top left and details up top etc, like how claude code opens.
6
+ * have you actually designed the page?"* — and then: *"no we want OUR logo"*.
7
+ *
8
+ * So this is the real mark, not letters spelling the name. It is
9
+ * `console/public/brand/acuvo-mark.png` — the angular A — resampled to
10
+ * half-blocks at 14x14 and embedded as text.
11
+ *
12
+ * ── ⚠️ WHY IT IS EMBEDDED RATHER THAN DECODED AT RUNTIME ────────────────────
13
+ *
14
+ * This package has ZERO dependencies, deliberately, and Node cannot decode a
15
+ * PNG without one. Converting at build time and pasting the result keeps that
16
+ * promise and costs nothing at startup. If the mark ever changes, re-run the
17
+ * conversion — the logo is DERIVED from the brand asset, not drawn by hand, so
18
+ * it stays honest to it.
19
+ *
20
+ * ── ⚠️ THE CONSTRAINTS, WHICH ARE NOT PREFERENCES ───────────────────────────
21
+ *
22
+ * · **Half-block glyphs only** (▀ ▄ █). Braille and box-drawing render as tofu
23
+ * in cmd.exe and in many CI log viewers, and a logo that renders as question
24
+ * marks is worse than no logo — on the exact platform this is developed on.
25
+ * · **No colour escapes here.** The caller owns colour and honours NO_COLOR; a
26
+ * module that hard-codes them emits garbage the moment output is piped.
27
+ * · **Every line under 80 columns.** The narrowest terminal in real use is 80,
28
+ * and a wrapped banner does not read as dense, it reads as broken.
29
+ *
30
+ * ⭐ A NOTE ON DOING BETTER: `lib/terminal-graphics.mjs` can send a real PNG
31
+ * inline on Kitty and iTerm2. It is deliberately NOT used here — Windows
32
+ * Terminal speaks neither protocol, so the block art is what most users would
33
+ * see anyway, and one rendering that is the same everywhere beats two that
34
+ * disagree.
35
+ */
36
+
37
+ /**
38
+ * The Acuvo mark, resampled from the brand PNG. Seven rows: tall enough to be
39
+ * the mark rather than a smudge, short enough for something run forty times a
40
+ * day rather than opened once.
41
+ */
42
+ const MARK = [
43
+ ' ▄█',
44
+ ' ▄███',
45
+ ' ▄█▀▀██',
46
+ ' ▄█▀ ▀██',
47
+ ' ▄█▀ ██ ▀██',
48
+ ' ▄████▀▀█████',
49
+ '█▀ █▀ ▀█',
50
+ ];
51
+
52
+ /**
53
+ * ── ⭐⭐⭐ THE SAME MARK, IN CHARACTERS NOTHING CAN STRETCH ─────────────────
54
+ *
55
+ * Every glyph here is ASCII, so it is width-1 by definition in every terminal,
56
+ * every font, every locale. No ambiguity class, nothing to measure, nothing to
57
+ * get wrong.
58
+ *
59
+ * ⚠️ THIS IS NOT "NO LOGO" — that distinction matters. Roman asked for OUR
60
+ * mark and then asked again when he got letters spelling the name. The fallback
61
+ * for a terminal that cannot draw half-blocks must therefore still BE the
62
+ * angular A, drawn a different way, rather than a wordmark standing in for it.
63
+ * A degraded logo is a logo; a text substitute is a missing one.
64
+ */
65
+ const MARK_ASCII = [
66
+ ' /\\',
67
+ ' / \\',
68
+ ' / /\\ \\',
69
+ ' / / \\ \\',
70
+ ' / /____\\ \\',
71
+ '/_/ \\_\\',
72
+ ];
73
+
74
+ const GUTTER = 2;
75
+
76
+ /**
77
+ * The widest the banner may ever be when the terminal will not say how wide it
78
+ * is. Kept at 80 because that is the narrowest terminal in real use, so a
79
+ * banner built to it fits everywhere.
80
+ */
81
+ export const MAX_BANNER_COLUMNS = 80;
82
+
83
+ /** Below this the two-column layout stops being a layout and starts being a mess. */
84
+ const MIN_TEXT_COLUMNS = 30;
85
+
86
+ /**
87
+ * Build the opening screen: mark on the left, facts on the right.
88
+ *
89
+ * @param {object} o
90
+ * @param {string} o.version
91
+ * @param {string} o.workspace already shortened by the caller
92
+ * @param {string} o.model
93
+ * @param {string} o.billing who this run will charge
94
+ * @param {string} o.canRun what it may execute, or that it may not
95
+ * @param {boolean} [o.interactive] whether a prompt follows
96
+ * @param {'blocks'|'text'} [o.style] which mark to draw see `lib/glyph-width.mjs`
97
+ * @param {number} [o.columns] the real terminal width, when it is known
98
+ * @returns {string}
99
+ */
100
+ export function openingScreen({
101
+ version, workspace, model, billing, canRun,
102
+ interactive = false, paint = null, style = 'blocks', columns = null,
103
+ }) {
104
+ const brand = paint?.brand ?? ((s) => s);
105
+
106
+ /**
107
+ * ── ⚠️⚠️ THE WIDTH IS THE TERMINAL'S, NOT A CONSTANT ──────────────────
108
+ *
109
+ * This used to build to a fixed 80 columns and never ask. Every line it
110
+ * produced was under 80 and it looked immaculate — in an 80-column terminal.
111
+ * In a narrower one, which is what a side panel or a split pane is, every
112
+ * single row wraps, and a wrapped banner does not read as dense. It reads as
113
+ * broken software, which is the first thing a new user sees.
114
+ *
115
+ * `- 1` because a line that exactly fills the width wraps on some terminals
116
+ * and not others, and the difference is not worth one column.
117
+ */
118
+ const width = Math.max(20, Math.min(MAX_BANNER_COLUMNS, (columns ?? MAX_BANNER_COLUMNS) - 1));
119
+
120
+ const mark = style === 'text' ? MARK_ASCII : MARK;
121
+ const markWidth = Math.max(...mark.map((l) => l.length));
122
+
123
+ /**
124
+ * THREE LAYOUTS, CHOSEN BY WHAT ACTUALLY FITS — never by a platform guess.
125
+ * Side by side when there is room for both; mark above the facts when there
126
+ * is room for the mark alone; facts only when there is not. The last one is
127
+ * rare and it still has to be right, because a 24-column terminal is somebody
128
+ * on a phone over SSH and they deserve a legible screen, not a torn one.
129
+ */
130
+ const sideBySide = width >= markWidth + GUTTER + MIN_TEXT_COLUMNS;
131
+ const textColumns = sideBySide ? width - markWidth - GUTTER : width;
132
+
133
+ /**
134
+ * ⚠️ ELIDED IN THE MIDDLE, NOT THE END. The informative parts of a path are
135
+ * the drive and the leaf; chopping the tail leaves `C:\Users\somebody\Projects\a-`,
136
+ * which identifies nothing. Same for a model id, where the family is at the
137
+ * front and the variant at the back.
138
+ */
139
+ const room = Math.max(8, textColumns - 11);
140
+ const fit = (v) => {
141
+ const s = String(v ?? '');
142
+ if (s.length <= room) return s;
143
+ const head = Math.ceil((room - 1) / 2);
144
+ return `${s.slice(0, head)}…${s.slice(s.length - (room - 1 - head))}`;
145
+ };
146
+
147
+ const right = [
148
+ `ACUVO CODE${version ? ` ${version}` : ''}`,
149
+ '',
150
+ `workspace ${fit(workspace)}`,
151
+ `model ${fit(model)}`,
152
+ `billing ${fit(billing)}`,
153
+ `can run ${fit(canRun)}`,
154
+ '',
155
+ ];
156
+
157
+ const lines = [''];
158
+
159
+ if (sideBySide) {
160
+ /**
161
+ * ⚠️ THE TWO COLUMNS ARE ZIPPED, NOT CONCATENATED, and the row counts are
162
+ * allowed to differ — whichever is shorter simply runs out.
163
+ */
164
+ const rows = Math.max(mark.length, right.length);
165
+ for (let i = 0; i < rows; i += 1) {
166
+ const text = right[i] ?? '';
167
+ /**
168
+ * ⚠️ PADDED FIRST, PAINTED SECOND — escape codes have no width, so padding
169
+ * a coloured string aligns text against invisible bytes and the whole
170
+ * right-hand column drifts.
171
+ *
172
+ * ⚠️⚠️ AND PADDED ONLY WHEN SOMETHING FOLLOWS. On a mark-only row the
173
+ * padding sits INSIDE the colour, before the reset, where `trimEnd`
174
+ * cannot reach it — so the coloured banner carried trailing whitespace the
175
+ * plain one did not. Invisible, but it means colour changed the layout.
176
+ */
177
+ const raw = mark[i] ?? '';
178
+ const padded = text ? raw.padEnd(markWidth + GUTTER) : raw;
179
+ lines.push(`${mark[i] ? brand(padded) : padded}${text}`.trimEnd());
180
+ }
181
+ } else {
182
+ const roomForMark = width >= markWidth;
183
+ if (roomForMark) {
184
+ for (const row of mark) lines.push(brand(row));
185
+ lines.push('');
186
+ }
187
+ for (const text of right) lines.push(text.trimEnd());
188
+ }
189
+
190
+ lines.push('');
191
+
192
+ /**
193
+ * ⚠️ ONLY WHEN A PROMPT ACTUALLY FOLLOWS. Printing "type what you want done"
194
+ * above a one-shot run that has already been given its task is an instruction
195
+ * for something the user cannot do.
196
+ *
197
+ * ⭐ And it is SHORTENED rather than wrapped when the terminal is narrow. A
198
+ * hint that wraps onto a second line looks like an error message.
199
+ */
200
+ if (interactive) {
201
+ /**
202
+ * ⭐ SHORTENED IN STAGES, NEVER TRUNCATED. A clamp would cut "leave" to
203
+ * "leav", and a hint with a word chopped in half does not read as a
204
+ * compact hint — it reads as a rendering bug, which is precisely the
205
+ * impression this screen keeps making.
206
+ */
207
+ const hints = [
208
+ ' Type what you want done. /help for commands · exit to leave',
209
+ ' /help for commands · exit to leave',
210
+ ' /help · exit',
211
+ ];
212
+ lines.push(hints.find((h) => h.length <= width) ?? hints[hints.length - 1], '');
213
+ }
214
+
215
+ /**
216
+ * ⚠️⚠️ THE LAST WORD ON WIDTH, AND IT IS NOT A BELT-AND-BRACES CHECK.
217
+ * Everything above reasons about `.length`, which counts CODE UNITS, while a
218
+ * terminal counts CELLS — and the whole reason this file was rewritten is that
219
+ * those two disagree. This clamp is measured on the painted string with the
220
+ * escapes discounted, so a mark that turns out wider than advertised is
221
+ * truncated rather than allowed to wrap and tear the layout.
222
+ */
223
+ return lines.map((l) => clampToWidth(l, width)).join('\n');
224
+ }
225
+
226
+ /** Visible length, ignoring ANSI escapes — they occupy no cells. */
227
+ function visibleLength(s) {
228
+ return String(s).replace(/\[[0-9;]*m/g, '').length;
229
+ }
230
+
231
+ /**
232
+ * Cut a possibly-coloured string to `width` visible cells, keeping the escapes
233
+ * balanced so a truncation cannot leak colour into the rest of the screen.
234
+ */
235
+ function clampToWidth(s, width) {
236
+ if (visibleLength(s) <= width) return s;
237
+ let out = '';
238
+ let seen = 0;
239
+ const re = /(\[[0-9;]*m)|([\s\S])/g;
240
+ let m;
241
+ while ((m = re.exec(s)) !== null) {
242
+ if (m[1]) { out += m[1]; continue; }
243
+ if (seen >= width) break;
244
+ out += m[2];
245
+ seen += 1;
246
+ }
247
+ /**
248
+ * ⚠️ ONLY RE-CLOSE A STRING THAT WAS ACTUALLY COLOURED. Appending a reset
249
+ * unconditionally puts four bytes on the end of every truncated PLAIN line —
250
+ * harmless on a terminal, and garbage the moment output is piped to a file or
251
+ * a CI log, which is the one place this module has already been told never to
252
+ * write escapes.
253
+ */
254
+ return out.includes('') ? `${out}` : out;
255
+ }
package/lib/chat.mjs CHANGED
@@ -370,7 +370,7 @@ export async function runChat({
370
370
  * Pressed against the banner it read as a fifth detail row rather than as an
371
371
  * instruction addressed to the person.
372
372
  */
373
- output.write('\nType what you want done. "/help" for commands, "exit" to leave.\n\n');
373
+ const writeInvitation = () => output.write('\nType what you want done. "/help" for commands, "exit" to leave.\n\n');
374
374
 
375
375
  let history = null;
376
376
  let turns = 0;
@@ -412,6 +412,12 @@ export async function runChat({
412
412
  * scrollback in existence runs.
413
413
  */
414
414
  writeBanner();
415
+ /**
416
+ * ⚠️ AFTER THE BANNER, AND IT WAS PRINTING BEFORE IT. The invitation is
417
+ * addressed to somebody who has just read the banner; printed above it, it is
418
+ * an instruction for a screen they have not seen yet.
419
+ */
420
+ writeInvitation();
415
421
 
416
422
  try {
417
423
  for (;;) {
@@ -0,0 +1,169 @@
1
+ /**
2
+ * ── ⭐⭐⭐ ASK THE TERMINAL HOW WIDE A GLYPH ACTUALLY IS ─────────────────────
3
+ *
4
+ * Roman, 2026-08-22, third report on the same screen: *"acuvo cli still is
5
+ * structured wrong, I can't even see our logo, and the words etc, like it
6
+ * doesn't fit."*
7
+ *
8
+ * ── ⚠️ WHY THE BANNER CAN BE CORRECT AND STILL SHATTER ──────────────────────
9
+ *
10
+ * The mark is built from half-block glyphs (▀ ▄ █, U+2580–U+2588). Unicode
11
+ * classifies them as **East Asian Ambiguous**, which means their width is not a
12
+ * property of the character — it is a property of the terminal and the font. A
13
+ * terminal that renders them at TWO cells turns a 14-column mark into 28
14
+ * columns, while the SPACES padding each row stay at one. The art does not get
15
+ * uniformly wider; it tears, every row by a different amount, and the right-hand
16
+ * column is shoved off the screen. That is exactly "I can't even see our logo,
17
+ * and the words don't fit", and it is invisible from any machine where the font
18
+ * happens to render them narrow — including mine, where the banner measures a
19
+ * tidy 72 columns and looks perfect.
20
+ *
21
+ * ⭐ SO STOP GUESSING AND MEASURE. Print the glyph, ask the terminal where the
22
+ * cursor ended up (`ESC[6n`, the DSR cursor-position report from ECMA-48), and
23
+ * subtract. The answer is not an inference about fonts or platforms; it is the
24
+ * terminal reporting its own behaviour. This is what every serious TUI does for
25
+ * emoji and CJK, and it is the only instrument that works from here — three
26
+ * previous attempts at this screen were reasoned from byte sequences and all
27
+ * three were wrong on the one machine that mattered.
28
+ *
29
+ * ── ⚠️ AND IT MUST FAIL SAFE, BECAUSE IT TOUCHES THE SCREEN ─────────────────
30
+ *
31
+ * The probe writes a character and reads a reply. If the terminal never answers
32
+ * — a dumb TERM, a pipe, a CI log, an editor's embedded console that swallows
33
+ * DSR — it must give up quickly, erase what it wrote, restore raw mode exactly
34
+ * as it found it, and return `null` for "unknown". A diagnostic that hangs the
35
+ * program it is diagnosing, or that leaves the terminal in raw mode after a
36
+ * failure, is worse than the bug.
37
+ */
38
+
39
+ /** The DSR request: "report the cursor position". */
40
+ const CURSOR_QUERY = '\x1b[6n';
41
+
42
+ /**
43
+ * Parse a cursor-position report, `ESC [ row ; col R`.
44
+ *
45
+ * ⚠️ SCANS FOR THE PATTERN RATHER THAN ANCHORING AT THE START. The reply can
46
+ * arrive glued to whatever else the user typed — a keystroke that landed during
47
+ * the round trip sits in the same chunk — and an anchored match would discard a
48
+ * perfectly good measurement because somebody pressed a key.
49
+ *
50
+ * @param {string} buf
51
+ * @returns {{ row: number, col: number } | null}
52
+ */
53
+ export function parseCursorReport(buf) {
54
+ const m = /\x1b\[(\d+);(\d+)R/.exec(String(buf ?? ''));
55
+ if (!m) return null;
56
+ const row = Number(m[1]);
57
+ const col = Number(m[2]);
58
+ if (!Number.isFinite(row) || !Number.isFinite(col) || col < 1) return null;
59
+ return { row, col };
60
+ }
61
+
62
+ /**
63
+ * Anything left over once the report is removed — the user's keystrokes.
64
+ *
65
+ * ⚠️ THEY MUST BE HANDED BACK, NOT DROPPED. A character typed while the probe
66
+ * was in flight belongs to the program, and eating it makes the very first
67
+ * keystroke of a session vanish at random.
68
+ *
69
+ * @param {string} buf
70
+ * @returns {string}
71
+ */
72
+ export function residualInput(buf) {
73
+ return String(buf ?? '').replace(/\x1b\[\d+;\d+R/, '');
74
+ }
75
+
76
+ /**
77
+ * How many cells the terminal gives `glyph`.
78
+ *
79
+ * @param {object} o
80
+ * @param {string} [o.glyph] the character to measure
81
+ * @param {NodeJS.ReadStream} o.input
82
+ * @param {NodeJS.WriteStream} o.output
83
+ * @param {number} [o.timeoutMs]
84
+ * @returns {Promise<number|null>} 1, 2, … or null when the terminal did not say
85
+ */
86
+ export async function measureCellWidth({ glyph = '█', input, output, timeoutMs = 200 } = {}) {
87
+ if (!input?.isTTY || !output?.isTTY || typeof input.setRawMode !== 'function') return null;
88
+
89
+ const wasRaw = input.isRaw === true;
90
+ let done = false;
91
+ let buffered = '';
92
+
93
+ return new Promise((resolve) => {
94
+ /**
95
+ * ⚠️ ONE EXIT PATH, AND IT ALWAYS CLEANS UP. Every way out of this — a
96
+ * reply, a timeout, a stream error — goes through here, because a probe
97
+ * that returns early on the happy path and leaks a listener on the sad one
98
+ * is how a CLI ends up with a terminal stuck in raw mode after a hiccup.
99
+ */
100
+ const finish = (value) => {
101
+ if (done) return;
102
+ done = true;
103
+ clearTimeout(timer);
104
+ input.removeListener('data', onData);
105
+ try {
106
+ // Erase the probe glyph and put the cursor back at the start of the line.
107
+ output.write('\r\x1b[2K');
108
+ } catch { /* the stream went away; nothing to clean */ }
109
+ try {
110
+ if (!wasRaw) input.setRawMode(false);
111
+ } catch { /* likewise */ }
112
+ // Give any keystrokes that arrived during the probe back to the program.
113
+ const rest = residualInput(buffered);
114
+ if (rest) input.unshift?.(rest);
115
+ resolve(value);
116
+ };
117
+
118
+ const onData = (chunk) => {
119
+ buffered += String(chunk);
120
+ const report = parseCursorReport(buffered);
121
+ if (!report) return;
122
+ /**
123
+ * The cursor started at column 1, so the glyph consumed `col - 1` cells.
124
+ * Guarded because a terminal that answers nonsense should read as unknown
125
+ * rather than as a plausible-looking wrong number.
126
+ */
127
+ const cells = report.col - 1;
128
+ finish(cells >= 1 && cells <= 4 ? cells : null);
129
+ };
130
+
131
+ const timer = setTimeout(() => finish(null), timeoutMs);
132
+
133
+ try {
134
+ if (!wasRaw) input.setRawMode(true);
135
+ input.on('data', onData);
136
+ // `\r` first: measure from a known column, not from wherever we happened to be.
137
+ output.write(`\r${glyph}${CURSOR_QUERY}`);
138
+ } catch {
139
+ finish(null);
140
+ }
141
+ });
142
+ }
143
+
144
+ /**
145
+ * The decision the banner actually needs, with every override that matters.
146
+ *
147
+ * ⚠️ AN EXPLICIT SETTING OUTRANKS THE MEASUREMENT, ALWAYS. Detection is very
148
+ * good and will still be wrong somewhere, and when it is, the user must have a
149
+ * way to say so that does not require them to file a bug and wait for a
150
+ * release. `ACUVO_BANNER=text` (or `blocks`) is that way.
151
+ *
152
+ * ⚠️ AND UNKNOWN FALLS BACK TO **TEXT**, not to blocks. The two errors are not
153
+ * symmetric: choosing text on a terminal that could have drawn the mark costs a
154
+ * little beauty, while choosing blocks on a terminal that cannot costs the user
155
+ * a screen of torn garbage as their first impression of the product. This is
156
+ * the mistake that has now been reported three times, so the default leans away
157
+ * from it.
158
+ *
159
+ * @param {object} o
160
+ * @param {number|null} o.cellWidth measured, or null
161
+ * @param {Record<string,string|undefined>} [o.env]
162
+ * @returns {'blocks'|'text'}
163
+ */
164
+ export function bannerStyle({ cellWidth, env = {} }) {
165
+ const forced = String(env.ACUVO_BANNER ?? '').toLowerCase();
166
+ if (forced === 'text' || forced === 'ascii') return 'text';
167
+ if (forced === 'blocks' || forced === 'art') return 'blocks';
168
+ return cellWidth === 1 ? 'blocks' : 'text';
169
+ }
package/lib/input-box.mjs CHANGED
@@ -489,9 +489,34 @@ export function readBoxedLine({ input, output, history = [], onInterrupt = null,
489
489
  */
490
490
  export function pinRegion(output, { rows = 2, env = process.env } = {}) {
491
491
  const height = output?.rows ?? process.stdout?.rows ?? 0;
492
+ /**
493
+ * ── ⚠️⚠️⚠️ OFF BY DEFAULT. THREE ATTEMPTS, THREE DIFFERENT WRONG RESULTS ────
494
+ *
495
+ * Roman, across three builds: "you have to scroll down to see the prompt" —
496
+ * then, after the clear was fixed — "now it's just the box, everything else
497
+ * is gone."
498
+ *
499
+ * The write ORDER is provably correct (traced: clear, region, banner at row 1,
500
+ * input at the last two rows). It still renders wrong in his terminal, and a
501
+ * scroll region is a claim on somebody's whole screen that behaves differently
502
+ * in VS Code, Windows Terminal and cmd.exe. I cannot verify it in the terminal
503
+ * that matters from here, and shipping a fourth guess at somebody's display is
504
+ * worse than not having the feature.
505
+ *
506
+ * ⭐ AND THE SIMPLE VERSION GETS THE ACTUAL REQUIREMENT. Without a region the
507
+ * input is simply the LAST THING WRITTEN each turn, and every terminal
508
+ * auto-scrolls to its newest output — so it is always at the bottom of what
509
+ * you are looking at, always visible, with the transcript above it. That is
510
+ * what "stuck down the bottom" needs to mean; welding it to a physical screen
511
+ * row was my addition, not the requirement.
512
+ *
513
+ * ⚠️ KEPT, NOT DELETED, and still fully tested — `ACUVO_PIN=1` turns it on.
514
+ * The mechanism is correct and worth having once it can be verified in a real
515
+ * VS Code terminal rather than inferred from a byte trace.
516
+ */
492
517
  const enabled = Boolean(output?.isTTY)
493
518
  && height > rows + 4
494
- && String(env.ACUVO_NO_PIN ?? '') !== '1'
519
+ && String(env.ACUVO_PIN ?? '') === '1'
495
520
  && String(env.CI ?? '').toLowerCase() !== 'true';
496
521
 
497
522
  if (!enabled) return { enabled: false, release() {}, rows: 0, bottom: 0 };
package/lib/session.mjs CHANGED
@@ -436,23 +436,68 @@ const WITHHELD =
436
436
  '[withheld: this tool call touched a credential file, so its contents were not saved with the session. '
437
437
  + 'Read the file again in this run if you need it.]';
438
438
 
439
+ /**
440
+ * The keys a saved message may carry, in the order this module falls back to
441
+ * when the source message does not state one of its own.
442
+ *
443
+ * ⚠️ `role` IS PINNED FIRST AND THE REST FOLLOW THE SOURCE — see `orderedKeys`.
444
+ */
445
+ const MESSAGE_KEYS = ['role', 'content', 'name', 'tool_call_id', 'tool_calls'];
446
+
447
+ /**
448
+ * ── ⚠️⭐ THE SAVED MESSAGE MUST SERIALISE IN THE ORDER THE LIVE ONE DID ──────
449
+ *
450
+ * MEASURED 2026-08-22, on the end-to-end crash-and-resume run this file exists
451
+ * for. With the head truncation fixed the restored prompt matched the original
452
+ * for 13,305 of 13,700 characters — and then diverged, on this:
453
+ *
454
+ * live : {"role":"tool","tool_call_id":"c_write_file","name":…,"content":…}
455
+ * saved : {"role":"tool","content":…,"name":…,"tool_call_id":"c_write_file"}
456
+ *
457
+ * Same message, same bytes of meaning, different JSON. `turn.mjs` pushes tool
458
+ * replies as `{role, tool_call_id, name, content}`; this function rebuilt them
459
+ * as `{role, content, name, tool_call_id}` because that is the order the code
460
+ * happened to assign in. Nothing was lost and nothing was wrong — the payload
461
+ * simply stopped being byte-identical at the first tool result, which is round
462
+ * one of every real session.
463
+ *
464
+ * ⭐ SO THE SOURCE'S OWN KEY ORDER IS PRESERVED. Reading `Object.keys(message)`
465
+ * costs nothing and makes the property hold for message shapes this module has
466
+ * not been taught about yet, which an explicit hand-written order would not.
467
+ * `role` is forced first because a `tool_calls`-only object would otherwise
468
+ * bury it, and every consumer reads `role` first.
469
+ */
470
+ function orderedKeys(message) {
471
+ const seen = new Set(['role']);
472
+ const keys = ['role'];
473
+ for (const k of Object.keys(message ?? {})) {
474
+ if (!MESSAGE_KEYS.includes(k) || seen.has(k)) continue;
475
+ seen.add(k);
476
+ keys.push(k);
477
+ }
478
+ // Anything the source did not name (or named in a shape we skipped) still has
479
+ // to be emitted if we produce a value for it — appended, never interleaved.
480
+ for (const k of MESSAGE_KEYS) if (!seen.has(k)) { seen.add(k); keys.push(k); }
481
+ return keys;
482
+ }
483
+
439
484
  /**
440
485
  * Scrub and cap one message. Returns a NEW object — the caller's array belongs
441
486
  * to a live session that may still be in use, and mutating it here would edit
442
487
  * the conversation a running loop is about to send.
443
488
  */
444
489
  function sanitizeMessage(message, { withhold = false, maxChars = MAX_MESSAGE_CHARS } = {}) {
445
- const out = { role: message?.role };
490
+ const value = {};
446
491
  let redactions = 0;
447
492
 
448
493
  if (typeof message?.content === 'string') {
449
494
  if (withhold) {
450
- out.content = WITHHELD;
495
+ value.content = WITHHELD;
451
496
  redactions += 1;
452
497
  } else {
453
498
  const r = redactSecrets(message.content);
454
499
  redactions += r.redactions;
455
- out.content = truncate(r.text, maxChars);
500
+ value.content = truncate(r.text, maxChars);
456
501
  }
457
502
  } else if (message?.content !== undefined) {
458
503
  // Non-string content (an array of parts, from a multimodal round). Keep the
@@ -460,14 +505,14 @@ function sanitizeMessage(message, { withhold = false, maxChars = MAX_MESSAGE_CHA
460
505
  // assistant message is another way to earn a 400.
461
506
  const r = redactSecrets(JSON.stringify(message.content));
462
507
  redactions += r.redactions;
463
- out.content = truncate(r.text, maxChars);
508
+ value.content = truncate(r.text, maxChars);
464
509
  }
465
510
 
466
- if (typeof message?.name === 'string') out.name = message.name;
467
- if (typeof message?.tool_call_id === 'string') out.tool_call_id = message.tool_call_id;
511
+ if (typeof message?.name === 'string') value.name = message.name;
512
+ if (typeof message?.tool_call_id === 'string') value.tool_call_id = message.tool_call_id;
468
513
 
469
514
  if (Array.isArray(message?.tool_calls)) {
470
- out.tool_calls = message.tool_calls.map((call) => {
515
+ value.tool_calls = message.tool_calls.map((call) => {
471
516
  const raw = String(call?.function?.arguments ?? '{}');
472
517
  // ⚠️ The ARGUMENTS of a write_file to `.env` contain the file body. The
473
518
  // reply is not the only place a credential lives.
@@ -487,6 +532,12 @@ function sanitizeMessage(message, { withhold = false, maxChars = MAX_MESSAGE_CHA
487
532
  };
488
533
  });
489
534
  }
535
+
536
+ const out = {};
537
+ for (const key of orderedKeys(message)) {
538
+ if (key === 'role') { out.role = message?.role; continue; }
539
+ if (value[key] !== undefined) out[key] = value[key];
540
+ }
490
541
  return { message: out, redactions };
491
542
  }
492
543
 
@@ -1035,6 +1086,42 @@ export function findCrashedSession(root, { limit = 5, selfPid = process.pid, isA
1035
1086
  return { ok: true, crashed: null };
1036
1087
  }
1037
1088
 
1089
+ /**
1090
+ * ── ⭐⭐ CLOSE THE BOOK ON A CRASHED RECORD ONCE IT HAS BEEN DEALT WITH ──────
1091
+ *
1092
+ * ⚠️ WITHOUT THIS THE OFFER IS IMMORTAL, and that is not a small defect — it is
1093
+ * the one that makes the whole feature useless. The crash marker lives in the
1094
+ * record, so a session recovered on Monday still says "I never finished" on
1095
+ * Tuesday, Wednesday and every run after that. A warning that fires when nothing
1096
+ * is wrong teaches people to dismiss it without reading, and then the real one
1097
+ * gets dismissed too.
1098
+ *
1099
+ * ⭐ IT IS CALLED ON BOTH ANSWERS — accepted and declined. Accepting carries the
1100
+ * conversation into a NEW record, so the old one is history. Declining is a
1101
+ * decision, and re-asking somebody who already said no is how a prompt becomes
1102
+ * noise. Neither answer deletes anything: the record stays listable, replayable
1103
+ * and `--resume <id>`-able, it simply stops volunteering.
1104
+ *
1105
+ * ⚠️ IT NEVER THROWS AND IT NEVER PARTIALLY WRITES. Same temp-then-rename as
1106
+ * `saveSession`, and an unreadable or unparseable file is reported, not raised —
1107
+ * this runs on the startup path of an ordinary run.
1108
+ *
1109
+ * @param {string} root
1110
+ * @param {string} id
1111
+ * @returns {{ ok: true, id: string, changed: boolean } | SessionRefused}
1112
+ */
1113
+ export function markSessionClosed(root, id) {
1114
+ const loaded = loadSession(root, id);
1115
+ if (!loaded.ok) return loaded;
1116
+ if (loaded.session.closedCleanly !== false) return { ok: true, id: loaded.session.id, changed: false };
1117
+
1118
+ const f = resolveSessionFile(root, `${String(id).replace(/\.json$/, '')}.json`);
1119
+ if (!f.ok) return f;
1120
+ const written = writeRecord(f, { ...loaded.session, closedCleanly: true });
1121
+ if (!written.ok) return written;
1122
+ return { ok: true, id: loaded.session.id, changed: true };
1123
+ }
1124
+
1038
1125
  /**
1039
1126
  * Does this pid still answer?
1040
1127
  *
package/lib/turn.mjs CHANGED
@@ -2349,6 +2349,20 @@ export async function runSession({
2349
2349
  * default is `OPEN_POLICY`, so a run with no policy file is unchanged.
2350
2350
  */
2351
2351
  policy = OPEN_POLICY,
2352
+ /**
2353
+ * ── ⭐⭐ CALLED AT EVERY ROUND BOUNDARY WITH THE RUN SO FAR ────────────────
2354
+ *
2355
+ * `null` (the default) is one falsy check per round and the behaviour this
2356
+ * function has always had. `bin/acuvo.mjs` passes `saveSession`, which is what
2357
+ * makes a killed run recoverable — see the call site inside the loop for the
2358
+ * measurement that forced it.
2359
+ *
2360
+ * ⚠️ IT RECEIVES THE SAME SHAPE THIS FUNCTION RETURNS, deliberately, so the
2361
+ * caller has one consumer and not two. A checkpoint that needed its own
2362
+ * translation layer would be a second definition of the outcome, and the two
2363
+ * would drift the first time either grew a field.
2364
+ */
2365
+ onCheckpoint = null,
2352
2366
  }) {
2353
2367
  const continuing = Array.isArray(priorMessages) && priorMessages.length > 0;
2354
2368
  /**
@@ -3047,6 +3061,52 @@ export async function runSession({
3047
3061
  }
3048
3062
  onEvent({ type: 'round-start', round, of: maxRounds });
3049
3063
 
3064
+ /**
3065
+ * ── ⭐⭐⭐ THE TRANSCRIPT REACHES DISK *DURING* THE RUN, NOT AFTER IT ─────
3066
+ *
3067
+ * ⚠️ MEASURED 2026-08-22, which is the only reason this exists: a run was
3068
+ * SIGKILLed here, mid-round, after two completed rounds and two files
3069
+ * written — and `.acuvo/sessions/` did not exist at all afterwards. Every
3070
+ * save in this package happened after the loop, so the run a person most
3071
+ * wants back (the one that died) was the only one that left nothing.
3072
+ *
3073
+ * ⭐ THE TOP OF THE ROUND IS THE RIGHT SEAM, and it is ONE call site. Here,
3074
+ * `messages` holds everything through round N-1 and nothing partial: the
3075
+ * assistant reply for this round has not arrived, so there is no dangling
3076
+ * `tool_calls` group for the session's side-effect guard to have to drop.
3077
+ * Hooking the three `rounds.push` sites instead would be three copies of the
3078
+ * same decision — the shape that has cost this repo five separate bugs.
3079
+ *
3080
+ * ⚠️ WHAT IT COSTS ON A CRASH IS THE ROUND IN FLIGHT, and that is honest:
3081
+ * the killed round's tool call may have LANDED while its result never did,
3082
+ * which is precisely what `resumeMessages` now says out loud.
3083
+ *
3084
+ * ⚠️ AND IT CAN NEVER TAKE THE RUN DOWN. A bookkeeping write that throws
3085
+ * would kill the work it exists to protect — the same rule `audit.mjs` and
3086
+ * `checkpoint.mjs` already state. The caller is handed the SAME shape
3087
+ * `saveSession` consumes at the end of the run, so there is one definition
3088
+ * of "what a session record contains" rather than two.
3089
+ */
3090
+ if (onCheckpoint) {
3091
+ try {
3092
+ onCheckpoint({
3093
+ ok: true,
3094
+ stage: 'running',
3095
+ model: config.model,
3096
+ messages,
3097
+ executed,
3098
+ rounds,
3099
+ roundsUsed: rounds.length,
3100
+ maxRounds,
3101
+ stoppedBecause: 'in-progress',
3102
+ // ⭐ The same aggregator the finished outcome uses — a resumed run's
3103
+ // budget subtraction reads `usage.cost`, and a checkpoint that
3104
+ // reported nothing would hand a crashed run a fresh full ceiling.
3105
+ usage: aggregateUsage(rounds, prefixReadings),
3106
+ });
3107
+ } catch { /* a record must never cost the work it records */ }
3108
+ }
3109
+
3050
3110
  /**
3051
3111
  * ── ⭐ THE COUNTDOWN, INJECTED HERE AND NOWHERE ELSE ────────────────────
3052
3112
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acuvo-code",
3
- "version": "0.6.2",
3
+ "version": "0.6.4",
4
4
  "description": "Acuvo Code — the terminal client for the Acuvo capability registry. Zero dependencies, by design.",
5
5
  "type": "module",
6
6
  "bin": {