acuvo-code 0.2.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.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * ── ⭐⭐ WHICH FILES DID THIS TOOL CALL ACTUALLY CHANGE — ONE ANSWER ─────────
3
+ *
4
+ * ⚠️ THIS CONCEPT LIVED IN THREE PLACES AND THEY DISAGREED, which is how the
5
+ * package has already lost files:
6
+ *
7
+ * · `parallel.mjs:83` read `rec.mutatedPath ?? rec.result.path` — correct,
8
+ * and the comment above it records the bug that forced `mutatedPath` to
9
+ * exist at all: `see_page`'s `result.path` is the page it READ, so two
10
+ * tasks that merely looked at one page were reported as colliding over it
11
+ * and `--parallel` exited 1 on work that never conflicted.
12
+ * · `best-of.mjs:167` read `record.args?.path` — a DIFFERENT field. Measured
13
+ * by reading `write-many.mjs:128`: a `write_files` call carries `files[]`
14
+ * in its arguments and has no top-level `path`, so a winning attempt that
15
+ * wrote 45 files in one bulk call had NONE of them copied back out of the
16
+ * attempt directory. Silently, and reported as a success.
17
+ * · `subagent.mjs:91` read `rec.args?.path ?? rec.result?.path`, a third
18
+ * spelling again.
19
+ *
20
+ * ⭐ ONE FUNCTION, AND EVERY CALLER ASKS IT. A future tool that touches disk in
21
+ * a new shape gets taught here, once, instead of in three files that will not
22
+ * all be found — and the third one to be missed is always the one that matters.
23
+ *
24
+ * ⚠️ IT IS A LEAF ON PURPOSE: zero imports, node builtins included. `handoff`,
25
+ * `parallel` and `best-of` all need it and two of them import each other, so
26
+ * anything less than a leaf reintroduces a cycle.
27
+ */
28
+
29
+ /**
30
+ * @param {any} record a dispatcher record: `{ name, args, result, mutated, mutatedPath? }`
31
+ * @returns {string[]} workspace-relative paths, in the order the tool reports them
32
+ */
33
+ export function changedPaths(record) {
34
+ /**
35
+ * ⚠️ `mutated` IS THE GATE, NOT THE TOOL NAME. The dispatcher sets it
36
+ * (`tools.mjs:816` says so outright) precisely so a future tool that also
37
+ * touches disk cannot be missed by a name-based check.
38
+ */
39
+ if (!record?.mutated) return [];
40
+
41
+ const out = [];
42
+ const push = (p) => { if (typeof p === 'string' && p.trim() && !out.includes(p)) out.push(p); };
43
+
44
+ /**
45
+ * ── ⭐⭐ `written[]` — THE ONE MULTI-FILE SHAPE, AND NOW TWO TOOLS USE IT ──
46
+ *
47
+ * Read it FIRST. Such a record also carries `mutatedPath`, but only when
48
+ * exactly ONE file landed (`tools.mjs:954`), so trusting that field alone
49
+ * silently drops 44 of 45.
50
+ *
51
+ * ⭐ `delegate` WITH `write: true` REPORTS THE SAME SHAPE. That was a
52
+ * deliberate choice over inventing an `applied[]` field: a second spelling
53
+ * of "these files changed" would need an arm here, an arm in `report.mjs`,
54
+ * and an arm in every future reader — which is precisely the three-way
55
+ * disagreement described above. Reusing the shape means a delegated build
56
+ * is understood by every reader that already understood a bulk write, with
57
+ * no new line of code anywhere.
58
+ */
59
+ for (const f of record.result?.written ?? []) push(f?.path);
60
+
61
+ // ⚠️ `mutatedPath` BEFORE `result.path` — see the `see_page` note above.
62
+ if (out.length === 0) push(record.mutatedPath ?? record.result?.path);
63
+
64
+ /**
65
+ * ── ⚠️⚠️ `args.path` IS THE LAST RESORT, AND ITS ORDER IS THE WHOLE POINT ──
66
+ *
67
+ * This function has to be a strict SUPERSET of what each caller did before
68
+ * it, or consolidating three readers into one is how the fourth thing breaks.
69
+ * `best-of.mjs` read `args.path` and nothing else, so a record shaped only
70
+ * that way must still resolve — otherwise this "cleanup" would stop best-of
71
+ * applying files, which is the exact category of regression it was written to
72
+ * prevent.
73
+ *
74
+ * ⚠️ BUT IT MUST NEVER BE CONSULTED FIRST. `args.path` is the RAW STRING A
75
+ * MODEL WROTE; the fields above are what the tool RESOLVED and actually
76
+ * touched — `./src/a.ts` and `src/a.ts` are one file and only the resolved
77
+ * form says so. And `see_page`'s `args.path` is the page it READ while its
78
+ * `mutatedPath` is the screenshot it wrote, so reading arguments first would
79
+ * re-open the bug that forced `mutatedPath` into existence: two tasks that
80
+ * merely looked at one page, reported as colliding over it.
81
+ */
82
+ if (out.length === 0) push(record.args?.path);
83
+ return out;
84
+ }
package/lib/chat.mjs ADDED
@@ -0,0 +1,434 @@
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 { EXIT_INTERRUPTED } from './interrupt.mjs';
26
+ import { parseSlash, runSlashCommand } from './slash.mjs';
27
+ import { estimateMessagesTokens } from './compact.mjs';
28
+
29
+ /** What ends a session. `exit`/`quit` because both are muscle memory. */
30
+ const QUIT = new Set(['exit', 'quit', ':q', 'bye']);
31
+
32
+ /**
33
+ * ⚠️ CONTEXT GROWS UNBOUNDED AND A CODING SESSION IS THE WORST CASE — tool
34
+ * results carry whole files. Left alone, turn 30 sends everything from turns
35
+ * 1-29 and eventually 400s on a context-length error mid-thought.
36
+ *
37
+ * The trim keeps the SYSTEM message and the FIRST user message (the workspace
38
+ * context — the cacheable prefix, dropping it would cost more than it saves) and
39
+ * discards the oldest middle turns.
40
+ */
41
+ export const MAX_HISTORY_MESSAGES = 40;
42
+
43
+ /**
44
+ * ── 💰⭐⭐⭐ THE TRIM WAS A CONTINUOUS SLIDE, AND IT COST ~90% OF THE CACHE ──
45
+ *
46
+ * `messages.slice(-keep)` re-slices on EVERY turn once a session passes the
47
+ * cap, so the third message of the prompt is different every time. Prefix
48
+ * caching matches from the first token and stops at the first difference — so
49
+ * everything after the 2-message head was re-bought at full price, every turn,
50
+ * for the rest of the session.
51
+ *
52
+ * ⭐ MEASURED (no credits, no network — a simulated 60-turn session counting how
53
+ * much of each prompt is byte-identical to the previous one):
54
+ *
55
+ * CURRENT slice(-38) cache 9.2% history 38-40
56
+ * stepped high=48 low=40 cache 74.5% history 38-48
57
+ * stepped high=56 low=40 cache 85.3% history 38-56
58
+ * stepped high=64 low=40 cache 89.7% history 38-64 <- shipped
59
+ * stepped high=72 low=40 cache 92.0% history 38-72
60
+ *
61
+ * ⚠️ THE HEAD BEING STABLE IS NOT THE SAME AS THE PROMPT BEING CACHED, and
62
+ * measuring position 0 alone would have said this code was fine. The 2-message
63
+ * head never moved; the cacheable prefix was still 2 messages out of 40.
64
+ *
65
+ * ⭐ `LOW` IS TODAY'S CAP ON PURPOSE, so this can only ever keep MORE history
66
+ * than the rule it replaces — never less. That makes it a pure win rather than
67
+ * a trade of memory for money, and it is why HIGH was raised instead of LOW
68
+ * being lowered (console shipped 16/8, halving its window; the CLI holds whole
69
+ * files in tool results and cannot afford to forget more).
70
+ *
71
+ * ⚠️ RAISING `HIGH` COSTS TOKENS PER REQUEST, and the ceiling that matters is
72
+ * not here: `turn.mjs` compacts above CONTEXT_BUDGET_TOKENS (96k), which
73
+ * `compact.mjs` warns is the moment the cache discount dies for good. This is a
74
+ * MESSAGE-COUNT backstop, not the token bound — 64 messages of ordinary turns
75
+ * sit far under 96k, but a session whose tool results carry whole files can
76
+ * approach it, and then compaction (with its own hysteresis) takes over. 72 and
77
+ * 80 buy 2 more points of cache for materially more of that risk, which is why
78
+ * 64 is the shipped number and not the best one in the table.
79
+ */
80
+ export const HISTORY_HIGH_WATER = 64;
81
+ export const HISTORY_LOW_WATER = MAX_HISTORY_MESSAGES;
82
+
83
+ /**
84
+ * ⚠️ WELL UNDER `turn.mjs`'s CONTEXT_BUDGET_TOKENS (96,000), not near it. The
85
+ * request also carries the TOOL OFFER — measured at ~15,200 tokens for the
86
+ * CLI's 63 verbs — plus the system prompt, so history must leave room for both
87
+ * and still clear the line where compaction fires. 55k + ~15k offer + prompt
88
+ * sits comfortably inside 96k.
89
+ *
90
+ * ⭐ This is a CEILING, not a target: an ordinary session (~551 tokens/message,
91
+ * measured) reaches the 64-message cap at ~35k and never comes near it. It
92
+ * exists for the file-heavy session, which is exactly the one that would
93
+ * otherwise be pushed into permanent compaction by the higher message cap.
94
+ */
95
+ export const HISTORY_TOKEN_CEILING = 55_000;
96
+
97
+ export function trimHistory(messages, max = HISTORY_HIGH_WATER) {
98
+ if (!Array.isArray(messages)) return messages;
99
+ /**
100
+ * ⚠️⚠️ THE GATE MUST ASK BOTH QUESTIONS, AND MY FIRST VERSION ASKED ONLY ONE.
101
+ * It read `messages.length <= max` and returned early — so the token ceiling
102
+ * below was unreachable for exactly the session it was written for: 64 heavy
103
+ * messages are under the COUNT cap and ~137,000 tokens, and the function
104
+ * handed them straight back. The test reported 133,491 tokens and I had to
105
+ * measure to find that the ceiling was never consulted at all rather than
106
+ * being wrong.
107
+ */
108
+ const overBudget = estimateMessagesTokens(messages) > HISTORY_TOKEN_CEILING;
109
+ if (messages.length <= max && !overBudget) return messages;
110
+ const head = messages.slice(0, 2); // system + the context-bearing user turn
111
+ /**
112
+ * ⭐ THE HEAD MOVES IN STEPS, NOT EVERY TURN. `dropped` is a multiple of STEP,
113
+ * so it changes only when the conversation crosses the next boundary — and
114
+ * between boundaries the prompt is byte-identical to the previous turn's.
115
+ *
116
+ * ⚠️ NOT `length > HIGH ? slice(-LOW)`. That is still a continuous slide past
117
+ * the mark and is the exact mistake console's own guard records catching
118
+ * before it shipped.
119
+ */
120
+ /**
121
+ * ⚠️⚠️ THE LOW WATER MUST BE DERIVED FROM `max`, NOT PINNED TO A CONSTANT,
122
+ * AND I ALMOST SHIPPED IT PINNED. My first version was
123
+ * `Math.min(HISTORY_LOW_WATER, max)`. `runChat`'s default `maxHistory` was
124
+ * MAX_HISTORY_MESSAGES (40), which equals HISTORY_LOW_WATER — so `step`
125
+ * became `max(1, 0)` = 1, and one-message steps ARE the continuous slide this
126
+ * whole change exists to remove. The fix would have measured 89.6% in a
127
+ * simulation and done NOTHING on the only path that calls it.
128
+ *
129
+ * ⭐ 0.625 is 40/64 — the shipped ratio, so the default keeps exactly the
130
+ * numbers that were measured, and any other `max` still steps properly
131
+ * instead of silently degrading to a slide.
132
+ */
133
+ const low = Math.min(HISTORY_LOW_WATER, Math.floor(max * 0.625));
134
+ const step = Math.max(1, max - low);
135
+ const body = messages.slice(head.length);
136
+ const overflow = Math.max(0, messages.length - max);
137
+ let dropped = Math.ceil(overflow / step) * step;
138
+ /**
139
+ * ── ⚠️⚠️ AND A TOKEN CEILING, BECAUSE MESSAGE COUNT IS THE WRONG UNIT ──────
140
+ *
141
+ * MEASURED against the one real recorded session on disk (93,036 prompt
142
+ * tokens, 18 messages, `.acuvo/sessions/20260814-095636-99d4.json`):
143
+ *
144
+ * avg message ~551 tokens largest observed ~2,145 tokens
145
+ *
146
+ * at 40 msgs: typical ~22,000 worst case (all like the largest) ~85,800
147
+ * at 64 msgs: typical ~35,300 worst case ~137,300
148
+ *
149
+ * ⚠️ SO RAISING THE COUNT ALONE MOVES THE WORST CASE FROM JUST UNDER THE
150
+ * 96k CONTEXT BUDGET TO WELL OVER IT. Past that line `turn.mjs` compacts, and
151
+ * `compact.mjs` is explicit that compaction voids the cache discount and
152
+ * "once it starts, it never stops" — so a change made ENTIRELY to protect the
153
+ * cache would, in a file-heavy session, destroy it. A tool result carrying a
154
+ * whole file is not the exception in a coding session; it is the normal case.
155
+ *
156
+ * ⭐ THE CEILING DROPS IN THE SAME `step` BLOCKS. Any multiple of `step`
157
+ * keeps block alignment, so the prefix still changes only at boundaries and
158
+ * the caching win is untouched — this bounds the worst case without
159
+ * reintroducing a slide.
160
+ */
161
+ const budget = Math.max(1, HISTORY_TOKEN_CEILING - estimateMessagesTokens(head));
162
+ while (dropped < body.length && estimateMessagesTokens(body.slice(dropped)) > budget) {
163
+ dropped += step;
164
+ }
165
+ let tail = body.slice(dropped);
166
+ /**
167
+ * ⚠️ A `tool` MESSAGE WITHOUT ITS `assistant` TOOL CALL IS A HARD 400 from
168
+ * every OpenAI-shaped provider — "tool_call_id did not have a preceding
169
+ * message with tool_calls". Slicing mid-exchange produces exactly that, and it
170
+ * would surface as a mysterious API error thirty turns into a session.
171
+ */
172
+ while (tail.length > 0 && tail[0].role === 'tool') tail = tail.slice(1);
173
+ return [...head, ...tail];
174
+ }
175
+
176
+ /**
177
+ * One prompt line. Returns null on EOF (Ctrl-D, or a pipe that ran out).
178
+ *
179
+ * ⚠️ THE CLOSED CHECK IS NOT DEFENSIVE, IT IS THE PIPED CASE. Found by piping a
180
+ * list of prompts in: readline emits 'close' when the stream ends, and the NEXT
181
+ * `rl.question()` throws ERR_USE_AFTER_CLOSE. The first turn had already
182
+ * succeeded and written a real file, so the session crashed AFTER doing its job
183
+ * — the worst shape of failure, because the work looks lost.
184
+ *
185
+ * Scripted input matters beyond tests: piping a prompt list is how anyone would
186
+ * automate this.
187
+ */
188
+ function ask(rl, prompt, state) {
189
+ if (state.closed) return Promise.resolve(null);
190
+ return new Promise((resolve) => {
191
+ let answered = false;
192
+ const onClose = () => { if (!answered) resolve(null); };
193
+ rl.once('close', onClose);
194
+ rl.question(prompt, (line) => {
195
+ answered = true;
196
+ rl.removeListener('close', onClose);
197
+ resolve(line);
198
+ });
199
+ });
200
+ }
201
+
202
+ /**
203
+ * Run an interactive session.
204
+ *
205
+ * `runOne(task, priorMessages)` performs one turn and returns the session
206
+ * outcome — injected rather than imported so this loop is testable with a stub
207
+ * and never needs a model or a terminal in a test.
208
+ */
209
+ /**
210
+ * ── ⚠️ PIPED INPUT IS A DIFFERENT PROBLEM AND NEEDED A DIFFERENT ANSWER ──────
211
+ * `readline` on a non-TTY DRAINS the stream as fast as it can and emits 'close'
212
+ * the moment it ends. The model call for turn 1 takes seconds, by which point
213
+ * the interface is already closed and every later prompt is lost — measured:
214
+ * a three-line pipe ran exactly ONE turn and exited quietly, which is worse than
215
+ * crashing because it looks like it worked.
216
+ *
217
+ * So a pipe is read WHOLE and replayed from a queue. A TTY keeps the real
218
+ * readline loop, where a human types the next line after seeing the last answer.
219
+ * Two input shapes, two mechanisms — pretending they are the same is what broke.
220
+ */
221
+ async function readAllLines(input) {
222
+ const chunks = [];
223
+ for await (const chunk of input) chunks.push(chunk);
224
+ return Buffer.concat(chunks.map((c) => (typeof c === 'string' ? Buffer.from(c) : c)))
225
+ .toString('utf8')
226
+ .split(String.fromCharCode(10))
227
+ .map((l) => l.replace(String.fromCharCode(13), ''));
228
+ }
229
+
230
+ /**
231
+ * ── ⚠️⚠️⭐ READLINE EATS CTRL-C. MEASURED IN NODE'S OWN SOURCE ──────────────
232
+ *
233
+ * This is the finding that made an interrupt handler in `bin/acuvo.mjs`
234
+ * necessary-but-not-sufficient. Read out of `process.binding('natives')` on
235
+ * node v22.17.0, `internal/readline/interface.js`, the ttyWrite ctrl-key
236
+ * switch, verbatim:
237
+ *
238
+ * case 'c':
239
+ * if (this.listenerCount('SIGINT') > 0) {
240
+ * this.emit('SIGINT');
241
+ * } else {
242
+ * // This readline instance is finished
243
+ * this.close();
244
+ * this[kQuestionReject]?.(new AbortError('Aborted with Ctrl+C'));
245
+ * }
246
+ *
247
+ * ⚠️ So with a TTY readline open and NO `'SIGINT'` listener on the interface,
248
+ * Ctrl-C never reaches `process.on('SIGINT')` at all — readline just closes
249
+ * itself. The run in flight would have carried on to completion, for minutes,
250
+ * with the user's Ctrl-C having produced nothing on screen. That is strictly
251
+ * worse than the bug we set out to fix, and no amount of correct handling in
252
+ * `bin/acuvo.mjs` would have been reached.
253
+ *
254
+ * ⭐ So the interface takes a listener whose whole job is to hand the signal
255
+ * back to the process, where the one policy in `interrupt.mjs` decides between
256
+ * "stop after this round" and "quit now".
257
+ *
258
+ * ── ⚠️⚠️ AND A SYNTHETIC EMIT INTO AN EMPTY EMITTER DOES NOTHING ────────────
259
+ *
260
+ * `process.emit('SIGINT')` is plain `EventEmitter.emit` — it does NOT invoke
261
+ * the OS default action. `turn.mjs` installs the process signal handlers inside
262
+ * `runSession`, so before the first turn of a session there are ZERO listeners
263
+ * and the emit would return `false` having done absolutely nothing. Ctrl-C at
264
+ * the very first prompt would be inert.
265
+ *
266
+ * ⭐ Hence the count check and the explicit exit: **every path out of this
267
+ * function either aborts a run or ends the process.** That is the rule this
268
+ * whole feature is built on, and it is the one that is easy to break here.
269
+ */
270
+ export function deliverInterrupt({
271
+ emit = (sig) => process.emit(sig),
272
+ listenerCount = (sig) => process.listenerCount(sig),
273
+ exit = (code) => process.exit(code),
274
+ } = {}) {
275
+ if (listenerCount('SIGINT') > 0) {
276
+ emit('SIGINT');
277
+ return 'delegated';
278
+ }
279
+ exit(EXIT_INTERRUPTED);
280
+ return 'exited';
281
+ }
282
+
283
+ export async function runChat({
284
+ runOne,
285
+ render,
286
+ input = process.stdin,
287
+ output = process.stdout,
288
+ banner = '',
289
+ /**
290
+ * ⚠️ THE HIGH WATER MARK, NOT THE OLD FLAT CAP. This default is what makes
291
+ * the stepped trim REACH the live session — see the note in `trimHistory`
292
+ * about the version of this fix that measured 89.6% and changed nothing.
293
+ */
294
+ maxHistory = HISTORY_HIGH_WATER,
295
+ /**
296
+ * ⚠️ INJECTED SO A TEST CAN SEE IT. The real one exits the process, and a
297
+ * test that could not substitute it could only assert this feature by killing
298
+ * its own runner. Default is production behaviour, so no caller changes.
299
+ */
300
+ onInterrupt = deliverInterrupt,
301
+ /**
302
+ * ── ⭐ THE `/` SURFACE'S ONE SEAM ─────────────────────────────────────────
303
+ *
304
+ * Providers for the things a command reports on — skills, MCP servers, spend,
305
+ * the model. `bin/` owns where those facts come from; this loop only asks, for
306
+ * the same reason `workspace.mjs` takes `claimPath` and `journal` injected
307
+ * rather than importing them.
308
+ *
309
+ * ⚠️ `{}` BY DEFAULT, NOT `null`. Every command degrades to "not available in
310
+ * this session" on a missing provider (see `slash.mjs`), so an embedder that
311
+ * wires nothing still gets a working `/help` instead of a crash.
312
+ */
313
+ slashContext = {},
314
+ }) {
315
+ const interactive = input.isTTY === true;
316
+
317
+ // ⚠️ A pipe is drained up front — see readAllLines. Doing this lazily is what
318
+ // silently lost every prompt after the first.
319
+ const queued = interactive ? null : await readAllLines(input);
320
+ let queueIndex = 0;
321
+
322
+ const rl = interactive ? createInterface({ input, output, terminal: true }) : null;
323
+ const state = { closed: false };
324
+ /**
325
+ * ⚠️ ATTACHED ONCE, NOT PER QUESTION — and the per-question version is why the
326
+ * first fix did not work. The stream can end WHILE the model call is in
327
+ * flight, when no question is pending and therefore no listener is attached;
328
+ * `close` fires into nothing, the flag stays false, and the next question
329
+ * throws anyway. A session-lifetime listener sees it whenever it happens.
330
+ */
331
+ if (rl) rl.once('close', () => { state.closed = true; });
332
+ /**
333
+ * ⭐ THE ONE LINE THAT MAKES CTRL-C REACH THE RUN — see `deliverInterrupt`
334
+ * above for the Node source that proves it is needed. `on`, not `once`: an
335
+ * interactive session runs many turns and the SECOND Ctrl-C (the one that
336
+ * quits) has to arrive here too, or the escape hatch is a single-use one.
337
+ */
338
+ if (rl) rl.on('SIGINT', () => { onInterrupt(); });
339
+ if (banner) output.write(`${banner}\n`);
340
+ // ⚠️ `/help` IS ADVERTISED IN THE ONE LINE EVERY SESSION PRINTS. A command
341
+ // surface nobody is told about is the same defect item 14 closed for `--help`:
342
+ // the feature worked and nothing a stranger would read mentioned it.
343
+ output.write('Type what you want done. "/help" for commands, "exit" to leave.\n\n');
344
+
345
+ let history = null;
346
+ let turns = 0;
347
+ /**
348
+ * ⭐ SET BY `/skills <name>`, CONSUMED BY THE NEXT REAL TURN AND THEN CLEARED.
349
+ * A skill that was printed to the terminal would look loaded and be invisible
350
+ * to the model; this is the variable that makes the verb real.
351
+ */
352
+ let pendingInject = null;
353
+
354
+ try {
355
+ for (;;) {
356
+ const line = interactive
357
+ ? await ask(rl, '› ', state)
358
+ : (queueIndex < queued.length ? queued[queueIndex++] : null);
359
+ // Echo a piped prompt so a scripted transcript reads like a session.
360
+ if (!interactive && line !== null && line.trim()) output.write(`› ${line.trim()}
361
+ `);
362
+ // ⚠️ EOF is not an error. A closed pipe or Ctrl-D ends the session the
363
+ // same way "exit" does — treating it as a fault would print a stack trace
364
+ // at the end of every scripted run.
365
+ if (line === null) break;
366
+ const task = line.trim();
367
+ if (!task) continue;
368
+ if (QUIT.has(task.toLowerCase())) break;
369
+
370
+ /**
371
+ * ── ⭐ THE `/` SURFACE, BEFORE ANYTHING IS SENT TO A MODEL ────────────
372
+ *
373
+ * ⚠️ IT COSTS NOTHING AND MUST NOT COUNT AS A TURN. `/cost` is asked
374
+ * precisely by somebody watching their spend, and answering it by
375
+ * incrementing the turn counter and appending to the history would make
376
+ * the question change the answer.
377
+ *
378
+ * ⚠️ AN UNRECOGNISED COMMAND IS ANSWERED HERE AND NOT FORWARDED. Passing
379
+ * `/skil` to the model gets a confident essay about a typo; the one thing
380
+ * the person needed was the word `/skills`, which `slash.mjs` supplies.
381
+ * See its header for why `/etc/hosts` is NOT treated as a command.
382
+ */
383
+ const command = parseSlash(line.trim());
384
+ if (command) {
385
+ const result = runSlashCommand(command, slashContext);
386
+ for (const l of result.output ?? []) output.write(`${l}\n`);
387
+ output.write('\n');
388
+ if (result.effect === 'clear') history = null;
389
+ if (typeof result.inject === 'string' && result.inject) pendingInject = result.inject;
390
+ continue;
391
+ }
392
+
393
+ /**
394
+ * ⚠️ THE SKILL IS PREPENDED TO THE TASK, NOT SUBSTITUTED FOR IT. The user
395
+ * typed an instruction; the skill is context for it. And it is cleared
396
+ * BEFORE the call rather than after, so a turn that throws cannot leave it
397
+ * armed and silently attach it to an unrelated question later.
398
+ */
399
+ let sendTask = task;
400
+ if (pendingInject) {
401
+ sendTask = `${pendingInject}\n\n---\n\n${task}`;
402
+ pendingInject = null;
403
+ }
404
+
405
+ let outcome;
406
+ try {
407
+ outcome = await runOne(sendTask, history);
408
+ } catch (err) {
409
+ /**
410
+ * ⚠️ ONE BAD TURN MUST NOT END THE SESSION. A timeout or a provider blip
411
+ * after twenty minutes of context is infuriating if it drops everything;
412
+ * the history is still valid, so report and keep the prompt.
413
+ */
414
+ output.write(`\n ✖ that turn failed: ${String(err?.message || err)}\n\n`);
415
+ continue;
416
+ }
417
+
418
+ turns += 1;
419
+ render(outcome, output);
420
+
421
+ if (outcome?.ok && Array.isArray(outcome.messages)) {
422
+ history = trimHistory(outcome.messages, maxHistory);
423
+ } else if (!outcome?.ok) {
424
+ // A failed turn leaves history UNTOUCHED. Appending a turn that produced
425
+ // nothing would poison the next one with a dead exchange.
426
+ output.write(`\n (history unchanged — that turn did not complete)\n`);
427
+ }
428
+ output.write('\n');
429
+ }
430
+ } finally {
431
+ if (rl) rl.close();
432
+ }
433
+ return { turns };
434
+ }