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,1017 @@
1
+ /**
2
+ * ── ⭐⭐ SESSION PERSISTENCE — THE HORIZON PROBLEM, ATTACKED FROM THE SIDE ───
3
+ *
4
+ * Every run of this CLI starts from nothing. `runSession` builds a system
5
+ * prompt, pre-reads the workspace, spends rounds gathering, and then the
6
+ * process exits and all of it evaporates. The round cap is not a soft limit —
7
+ * `stoppedBecause: 'round-cap'` is the MOST COMMON way a real task ends, and
8
+ * when it does, the user's only recovery is to re-type the prompt and pay for
9
+ * the entire gather a second time.
10
+ *
11
+ * ⭐ THE PLAN LEDGER ALREADY PROVED HALF OF THIS. `plan-ledger.mjs` found that
12
+ * re-issuing an identical command spent 3 of its 4 rounds re-deriving what the
13
+ * previous process had held in memory seconds earlier, and it fixed the WHAT:
14
+ * the outstanding deliverables survive. This file fixes the HOW-MUCH-IS-KNOWN:
15
+ * the conversation itself survives, so a resumed run does not re-read the files
16
+ * it already read, re-run the searches it already ran, or re-discover the shape
17
+ * of a project it already walked. A plan says "3 steps left". A session says
18
+ * "and here is everything I learned getting to them."
19
+ *
20
+ * ── ⚠️ THE THREE RULES THAT MAKE THIS SAFE, AND WHY EACH ONE EXISTS ─────────
21
+ *
22
+ * 1. **NO SECRET IS EVER WRITTEN HERE.** A message history contains file
23
+ * CONTENTS — `gatherWorkspaceContext` pre-reads the tree, and `read_file`
24
+ * results are verbatim. `turn.mjs` documents the worst bug this package has
25
+ * had: that pre-load put `OPENROUTER_API_KEY=sk-or-v1-…`, a database
26
+ * password and a private key into the prompt, and the provider chain fanned
27
+ * them to four companies. Writing that same transcript to a FILE is the same
28
+ * leak with a longer half-life — a prompt is gone when the process is; a
29
+ * session sits in `.acuvo/` until someone tars the directory up.
30
+ *
31
+ * So two guards, matching the two the rest of the package already uses:
32
+ * · `refusedCommitPath` (git.mjs) — the credential-FILE list, reused rather
33
+ * than re-typed, exactly as `gatherWorkspaceContext` reuses it. A tool call
34
+ * that touched `.env` or `id_rsa` has its content WITHHELD, not redacted:
35
+ * the whole file is the secret, so there is nothing in it worth keeping.
36
+ * · a redactor for conventionally-named credentials, whose name pattern is
37
+ * the same one `scrubEnvironment` (command.mjs) applies to a child's
38
+ * environment. See SECRET_NAME below for why it is a copy and what pins it.
39
+ *
40
+ * ⚠️ AND IT REDACTS RATHER THAN REFUSING, WHICH IS THE OPPOSITE OF
41
+ * `plan-ledger.mjs`. That file refuses, because its input is the model's own
42
+ * words chosen milliseconds ago and it can simply choose different ones.
43
+ * Here the input is a transcript that already happened; refusing to persist
44
+ * it throws away an entire run's work to avoid writing eight characters we
45
+ * can just as easily replace. `audit.mjs` settled this argument first: a log
46
+ * that refuses to be written is a log that does not exist.
47
+ *
48
+ * 2. **A RESUME RECONSTRUCTS CONTEXT AND NEVER REPLAYS A SIDE EFFECT.** This is
49
+ * the rule with teeth. A recorded round looks like
50
+ * `assistant(tool_calls) → tool(result)`, and an assistant message carrying
51
+ * `tool_calls` is not a memory of an action — in an OpenAI-shaped payload it
52
+ * is a PENDING action. Hand back a history ending in a dangling
53
+ * `tool_calls` with no matching result and two things happen: most providers
54
+ * reject the conversation outright, and any that accept it invite the model
55
+ * to reissue the call it already made. If the call was `run_command`, the
56
+ * resume just re-ran it. So a trailing unanswered call group is DROPPED, and
57
+ * `dropDanglingCalls` below is the whole of that guarantee.
58
+ *
59
+ * This module spawns nothing, imports no `child_process`, and returns only
60
+ * inert message objects. `test/session.test.mjs` asserts that statically,
61
+ * because "it doesn't currently" is not a guarantee anyone can rely on.
62
+ *
63
+ * 3. **IT CANNOT GROW WITHOUT BOUND.** `.acuvo/` is a hidden directory in
64
+ * somebody's project. A file that quietly accumulates megabytes there is a
65
+ * bug even when every byte is correct. One session is capped
66
+ * (MAX_SESSION_BYTES), the directory is capped (MAX_SESSIONS), and both caps
67
+ * are enforced by DROPPING THE OLDEST, never by refusing to write the newest
68
+ * — a persistence layer whose failure mode is "your most recent run was not
69
+ * saved" is worse than useless, because you only find out when you need it.
70
+ *
71
+ * ── ⚠️ WHAT THIS DELIBERATELY DOES NOT DO ──────────────────────────────────
72
+ * · It does not wire itself in. `saveSession` is called by the caller that
73
+ * owns the loop; see REGISTRATION_SNIPPET at the bottom.
74
+ * · It offers the model NO resume tool. A model rewriting its own message
75
+ * history mid-run is precisely the side-effect replay hazard rule 2 exists
76
+ * to prevent, and there is no task for which it is the right answer.
77
+ * `session_list` is read-only and that is the entire model-facing surface.
78
+ * · It never judges a session. "Was the work correct" is the auditor's
79
+ * question; this file records only what happened and why it stopped.
80
+ */
81
+
82
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
83
+ import { dirname } from 'node:path';
84
+
85
+ import { resolveInWorkspace } from './workspace.mjs';
86
+ import { refusedCommitPath } from './secret-paths.mjs';
87
+ import { ensureAcuvoDirIgnored } from './acuvo-dir.mjs';
88
+
89
+ /** Scratch, alongside `plan.json`, `mcp.json` and the screenshots `see_page`
90
+ * writes. A session is scratch of exactly that kind: useful to the next run,
91
+ * never part of the user's source tree. */
92
+ export const SESSION_DIR = '.acuvo/sessions';
93
+ export const SESSION_VERSION = 1;
94
+
95
+ /** One session file. Roughly a large transcript; past this the value of the
96
+ * extra history is lower than the cost of reading it back into a prompt. */
97
+ export const MAX_SESSION_BYTES = 240_000;
98
+ /** How many session files may exist. Twenty is several days of real work and
99
+ * about five megabytes worst case. */
100
+ export const MAX_SESSIONS = 20;
101
+ /** Per-message ceiling. A single `read_file` of a 200KB source file would
102
+ * otherwise be most of the budget on its own. */
103
+ export const MAX_MESSAGE_CHARS = 8_000;
104
+ export const MAX_TASK_CHARS = 400;
105
+ /** The metadata lists are for a HUMAN reading `--sessions`, so they are short
106
+ * by intent — the full detail is in the messages. */
107
+ export const MAX_FILES_RECORDED = 200;
108
+ export const MAX_COMMANDS_RECORDED = 100;
109
+ export const MAX_COMMAND_OUTPUT_CHARS = 1_500;
110
+
111
+ /** `memory-workspace.mjs` names the disk-less executor this. */
112
+ const MEMORY_ROOT = '(memory)';
113
+ const MEMORY_REFUSAL =
114
+ 'this workspace has no disk, so a session cannot be saved — there is nothing to resume from and nothing to do about it here';
115
+
116
+ const err = (e) => (e instanceof Error ? e.message : String(e));
117
+
118
+ /**
119
+ * ── ⚠️ A DELIBERATE COPY OF `command.mjs`'s SECRET_NAME, AND WHAT PINS IT ───
120
+ *
121
+ * The brief for this module says to scrub "the same conventionally-named
122
+ * credentials the command executor scrubs". The honest way to do that would be
123
+ * to import the pattern — but `command.mjs` does not export it, and this module
124
+ * is not permitted to edit that file. A copy is therefore the only option, and
125
+ * an uncommented copy is how two definitions of "what is a secret" drift until
126
+ * one of them is wrong.
127
+ *
128
+ * ⭐ SO THE TEST PINS THEM WITHOUT EITHER FILE KNOWING ABOUT THE OTHER. It
129
+ * drives the REAL `scrubEnvironment` with a probe environment of ~30 variable
130
+ * names and asserts that every name it strips is a name this pattern matches,
131
+ * and vice versa. The two definitions cannot diverge silently; they can only
132
+ * diverge in a red test that names the offending variable.
133
+ *
134
+ * ⚠️ It is a denylist, and `command.mjs` already explains why that is the
135
+ * conscious exception here: an allowlist of variable names would refuse half of
136
+ * every real project's configuration. `MY_DB_STRING` survives it. This is one
137
+ * layer, not the boundary — rule 1's file-level withholding is the other.
138
+ */
139
+ const SECRET_NAME = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE|SESSION|COOKIE|AUTH|_DSN$|CONNECTION_STRING)/i;
140
+
141
+ /**
142
+ * Literal secrets, recognised by SHAPE rather than by the name next to them.
143
+ * Same list `plan-ledger.mjs` uses to REFUSE, used here to REPLACE — see the
144
+ * header for why the two files answer the same threat differently.
145
+ */
146
+ const SECRET_SHAPES = [
147
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '[redacted private key]'],
148
+ [/\b(?:sk|rk)-or-v1-[A-Za-z0-9_-]{16,}/g, '[redacted openrouter key]'],
149
+ [/\bsk-[A-Za-z0-9]{16,}/g, '[redacted api key]'],
150
+ [/\bAKIA[0-9A-Z]{16}\b/g, '[redacted aws key id]'],
151
+ [/\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, '[redacted github token]'],
152
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, '[redacted slack token]'],
153
+ ];
154
+
155
+ /**
156
+ * `NAME=value`, `NAME: value`, `"name": "value"` — the assignment form, which
157
+ * is how a credential appears inside a file the agent read rather than inside a
158
+ * key it happened to print.
159
+ *
160
+ * ⚠️ THE VALUE TEST IS WHAT KEEPS THIS FROM EATING THE TRANSCRIPT, AND THE
161
+ * FIRST VERSION OF IT WAS NOT ENOUGH. Matching on the NAME alone turns
162
+ * `let tokenCount = tokens.length;` into `let tokenCount = [redacted]` — caught
163
+ * by the test on the first run, and it is the failure that matters more than
164
+ * the leak in one specific way: a resumed session full of redacted CODE is
165
+ * worse than no resume at all, because the model reasons confidently about text
166
+ * that is not what the file says. A missing secret is a hole you can see.
167
+ *
168
+ * ⭐ SO THE DISCRIMINATOR IS THE FORM OF THE LINE, NOT THE NAME ON IT. Three
169
+ * shapes are credentials-in-a-file and everything else is code:
170
+ * · QUOTED — `"apiKey": "abcd1234efgh"`. A quoted literal next to a secret
171
+ * name is a hardcoded secret whether it is JSON, YAML or source.
172
+ * · ENV-STYLE — `DB_PASSWORD=hunter2hunter`, `export GITHUB_TOKEN=aaaa…`. No
173
+ * declaration keyword, `=`, no spaces around it: that is a
174
+ * dotenv line or a shell export, never an expression.
175
+ * · OPAQUE — an unquoted value with letters AND digits and no dotted
176
+ * access, not preceded by const/let/var. `hunter2secret` in a
177
+ * YAML file qualifies; `tokens.length` and `someVariable` do
178
+ * not.
179
+ * A `const`/`let`/`var` line with an unquoted value is never touched, which is
180
+ * what makes `apiKey = process.env.OPENROUTER_API_KEY` survive intact — and it
181
+ * should survive, because the thing it names is not in the file.
182
+ */
183
+ const ASSIGNMENT = /^([ \t]*(?:export[ \t]+)?)(const[ \t]+|let[ \t]+|var[ \t]+)?(["']?)([A-Za-z_][A-Za-z0-9_.-]*)\3([ \t]*)([:=])([ \t]*)(\S+)[ \t]*$/gm;
184
+ const CODE_SHAPED = /[()${}[\]]|process\.env|import\.meta|require\(/;
185
+
186
+ /**
187
+ * @param {unknown} text
188
+ * @returns {{ text: string, redactions: number }}
189
+ */
190
+ export function redactSecrets(text) {
191
+ if (typeof text !== 'string' || text === '') return { text: typeof text === 'string' ? text : '', redactions: 0 };
192
+ let out = text;
193
+ let hits = 0;
194
+ for (const [rx, replacement] of SECRET_SHAPES) {
195
+ out = out.replace(rx, () => { hits += 1; return replacement; });
196
+ }
197
+ out = out.replace(ASSIGNMENT, (whole, lead, decl, quote, name, before, sep, after, value) => {
198
+ if (!SECRET_NAME.test(name)) return whole;
199
+ if (CODE_SHAPED.test(value)) return whole;
200
+ const bare = value.replace(/^["'`]|["'`,;]+$/g, '');
201
+ if (bare.length < 8) return whole;
202
+
203
+ const quoted = /^["'`]/.test(value);
204
+ const envStyle = !decl && sep === '=' && before === '' && after === '';
205
+ const opaque = !decl && !bare.includes('.') && /[0-9]/.test(bare) && /[A-Za-z]/.test(bare);
206
+ if (!quoted && !envStyle && !opaque) return whole;
207
+
208
+ hits += 1;
209
+ return `${lead}${decl ?? ''}${quote}${name}${quote}${before}${sep}${after}[redacted]`;
210
+ });
211
+ return { text: out, redactions: hits };
212
+ }
213
+
214
+ /**
215
+ * Would persisting THIS path's contents be persisting a credential file?
216
+ *
217
+ * ⭐ Reuses git.mjs deliberately. That list is already the package's single
218
+ * answer to "files that must never leave this machine", it is already tested,
219
+ * and a second copy is the one that goes stale. `turn.mjs` reuses it for the
220
+ * same reason on the prompt path.
221
+ *
222
+ * @param {unknown} path
223
+ * @returns {boolean}
224
+ */
225
+ export function isCredentialPath(path) {
226
+ return typeof path === 'string' && path !== '' && refusedCommitPath(path) !== null;
227
+ }
228
+
229
+ /**
230
+ * ── ⚠️ NO COLONS. This is a FILENAME. ──────────────────────────────────────
231
+ * The obvious id is an ISO timestamp, and `2026-08-10T23:05:11.402Z` cannot be
232
+ * stored on Windows — `workspace.mjs` refuses `:` outright as a character the
233
+ * filesystem will not hold, so the save would fail on the owner's own laptop
234
+ * and nowhere else. `plan-ledger.mjs` hit the identical wall naming its
235
+ * quarantine files and solved it the same way.
236
+ *
237
+ * ⭐ UTC AND FIXED-WIDTH, so lexical order IS chronological order. `listSessions`
238
+ * and `pruneSessions` then need no stat call and no date parsing to know which
239
+ * session is the oldest — a sort of the directory listing is the answer.
240
+ */
241
+ export function newSessionId(now = new Date()) {
242
+ const iso = new Date(now).toISOString();
243
+ const stamp = `${iso.slice(0, 10).replace(/-/g, '')}-${iso.slice(11, 19).replace(/:/g, '')}`;
244
+ const salt = Math.random().toString(36).slice(2, 6);
245
+ return `${stamp}-${salt}`;
246
+ }
247
+
248
+ const ID_SHAPE = /^[0-9]{8}-[0-9]{6}-[a-z0-9]{2,8}$/;
249
+
250
+ /**
251
+ * Resolve a file inside the session directory and PROVE it is inside it.
252
+ *
253
+ * ⚠️ EXPORTED FOR THE SAME REASON `resolvePlanFile` IS. Every caller in this
254
+ * module passes an id that this module generated, so the prefix assertion can
255
+ * never fire in production — which is exactly why it would rot unnoticed if it
256
+ * were private. The test drives it with `../../outside`, an absolute path and a
257
+ * traversal spelling.
258
+ *
259
+ * @returns {{ ok: true, absolute: string, relative: string, root: string } | { ok: false, error: string }}
260
+ */
261
+ export function resolveSessionFile(root, name) {
262
+ if (typeof root !== 'string' || root.trim() === '') {
263
+ return { ok: false, error: 'no workspace directory was given, so a session cannot be stored' };
264
+ }
265
+ if (root === MEMORY_ROOT) return { ok: false, error: MEMORY_REFUSAL };
266
+ if (typeof name !== 'string' || name.trim() === '') {
267
+ return { ok: false, error: 'a session needs an id — call newSessionId(), or pass one from list_sessions' };
268
+ }
269
+ /**
270
+ * ⚠️ A SESSION ID IS ONE FILENAME, NOT A PATH — and the first version of this
271
+ * function did not say so. `resolveInWorkspace` is handed `${SESSION_DIR}/${name}`,
272
+ * so an id of `/etc/passwd` normalised to `.acuvo/sessions/etc/passwd`: still
273
+ * safely inside the workspace, but a nested directory nobody asked for, and a
274
+ * containment check that returns `ok` for `/etc/passwd` reads like a hole even
275
+ * when it is not one. Caught by the test that drives this with absolute paths.
276
+ *
277
+ * The single-segment rule is also what keeps the id and the FILENAME the same
278
+ * string, which is what `listSessions` and `pruneSessions` both rely on.
279
+ */
280
+ if (/[\\/]/.test(name)) {
281
+ return { ok: false, error: `a session id is a single name, not a path — "${name}" contains a directory separator` };
282
+ }
283
+
284
+ const r = resolveInWorkspace(root, `${SESSION_DIR}/${name}`, 'write');
285
+ if (!r.ok) return { ok: false, error: r.reason };
286
+ if (!r.relative.startsWith(`${SESSION_DIR}/`)) {
287
+ return { ok: false, error: `sessions are only ever written inside ${SESSION_DIR}/ — "${r.relative}" is outside it` };
288
+ }
289
+ return { ok: true, absolute: r.absolute, relative: r.relative, root: r.root };
290
+ }
291
+
292
+ function truncate(text, max) {
293
+ const s = String(text ?? '');
294
+ if (s.length <= max) return s;
295
+ return `${s.slice(0, max)}\n… [truncated ${s.length - max} characters — this is a saved session, not the live file; read the file again if you need the rest]`;
296
+ }
297
+
298
+ /**
299
+ * ── THE PAIRING RULE ───────────────────────────────────────────────────────
300
+ * An OpenAI-shaped conversation is rejected outright when a `tool` message
301
+ * references an id that no preceding `tool_calls` declared — `turn.mjs` already
302
+ * documents that as an HTTP 400 which reads like a bug in the prompt. Trimming
303
+ * a history one message at a time breaks that invariant on the first cut, so
304
+ * this module never handles messages individually: it groups them, and a group
305
+ * is atomic.
306
+ *
307
+ * · an assistant message WITH tool_calls, plus every `tool` message answering
308
+ * it, is ONE group
309
+ * · anything else is a group of one
310
+ *
311
+ * @param {any[]} messages
312
+ * @returns {{ head: any[], groups: any[][] }}
313
+ */
314
+ function groupMessages(messages) {
315
+ const head = [];
316
+ const groups = [];
317
+ let i = 0;
318
+ // The system message and the opening user message are the cacheable prefix —
319
+ // the task, the workspace shape, the rules. They are never candidates for
320
+ // dropping, because a history without them is not a resume, it is a new run
321
+ // with confusing extra context.
322
+ while (i < messages.length && messages[i]?.role === 'system') head.push(messages[i++]);
323
+ if (i < messages.length && messages[i]?.role === 'user') head.push(messages[i++]);
324
+
325
+ while (i < messages.length) {
326
+ const m = messages[i];
327
+ if (m?.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
328
+ const group = [m];
329
+ i += 1;
330
+ while (i < messages.length && messages[i]?.role === 'tool') group.push(messages[i++]);
331
+ groups.push(group);
332
+ continue;
333
+ }
334
+ groups.push([m]);
335
+ i += 1;
336
+ }
337
+ return { head, groups };
338
+ }
339
+
340
+ /**
341
+ * ── ⚠️⭐ THE SIDE-EFFECT GUARD ─────────────────────────────────────────────
342
+ *
343
+ * A session can stop in the middle of a round: `stoppedBecause: 'model-error'`
344
+ * breaks the loop right after the assistant message was pushed, and a Ctrl-C
345
+ * lands wherever it lands. What survives is an assistant message carrying
346
+ * `tool_calls` that nothing ever answered.
347
+ *
348
+ * That is not a record of an action. In a replayed payload it is a PENDING
349
+ * action, and the two things that happen next are both bad: strict providers
350
+ * reject the whole conversation, and lenient ones let the model helpfully
351
+ * reissue the call. If the call was `run_command`, the resume just re-ran it —
352
+ * silently, as its first act, before the user has said anything.
353
+ *
354
+ * So an unanswered call group is dropped, and the caller is TOLD how many, so
355
+ * the resume can say "the last round was incomplete and was discarded" rather
356
+ * than pretending the history is whole.
357
+ *
358
+ * ⚠️ It drops the GROUP, never just the assistant message. Keeping the partial
359
+ * tool replies would leave orphaned `tool` messages — the exact 400 the pairing
360
+ * rule exists to prevent, arrived at from the other direction.
361
+ */
362
+ function dropDanglingCalls(groups) {
363
+ const kept = [];
364
+ let dropped = 0;
365
+ for (const group of groups) {
366
+ const head = group[0];
367
+ if (head?.role === 'assistant' && Array.isArray(head.tool_calls) && head.tool_calls.length > 0) {
368
+ const answered = new Set(group.slice(1).map((m) => m?.tool_call_id));
369
+ const unanswered = head.tool_calls.filter((c) => !answered.has(c?.id));
370
+ if (unanswered.length > 0) { dropped += 1; continue; }
371
+ }
372
+ kept.push(group);
373
+ }
374
+ return { groups: kept, dropped };
375
+ }
376
+
377
+ /**
378
+ * Which tool_call ids touched a credential file?
379
+ *
380
+ * The leak this closes is specific and easy to miss: the model calls
381
+ * `read_file` on `.env`, and the SECRET is not in the assistant message (which
382
+ * contains only the path) — it is in the `tool` message that answers it. So the
383
+ * ids have to be collected from the call and applied to the reply.
384
+ */
385
+ function credentialCallIds(group) {
386
+ const head = group[0];
387
+ const ids = new Set();
388
+ if (head?.role !== 'assistant' || !Array.isArray(head.tool_calls)) return ids;
389
+ for (const call of head.tool_calls) {
390
+ let args = {};
391
+ try { args = JSON.parse(call?.function?.arguments || '{}'); } catch { /* an unparseable call cannot name a path */ }
392
+ const candidates = [args.path, args.file, args.filename, args.pattern];
393
+ if (candidates.some((p) => isCredentialPath(p))) ids.add(call?.id);
394
+ }
395
+ return ids;
396
+ }
397
+
398
+ const WITHHELD =
399
+ '[withheld: this tool call touched a credential file, so its contents were not saved with the session. '
400
+ + 'Read the file again in this run if you need it.]';
401
+
402
+ /**
403
+ * Scrub and cap one message. Returns a NEW object — the caller's array belongs
404
+ * to a live session that may still be in use, and mutating it here would edit
405
+ * the conversation a running loop is about to send.
406
+ */
407
+ function sanitizeMessage(message, { withhold = false, maxChars = MAX_MESSAGE_CHARS } = {}) {
408
+ const out = { role: message?.role };
409
+ let redactions = 0;
410
+
411
+ if (typeof message?.content === 'string') {
412
+ if (withhold) {
413
+ out.content = WITHHELD;
414
+ redactions += 1;
415
+ } else {
416
+ const r = redactSecrets(message.content);
417
+ redactions += r.redactions;
418
+ out.content = truncate(r.text, maxChars);
419
+ }
420
+ } else if (message?.content !== undefined) {
421
+ // Non-string content (an array of parts, from a multimodal round). Keep the
422
+ // shape by stringifying rather than dropping it — a missing `content` on an
423
+ // assistant message is another way to earn a 400.
424
+ const r = redactSecrets(JSON.stringify(message.content));
425
+ redactions += r.redactions;
426
+ out.content = truncate(r.text, maxChars);
427
+ }
428
+
429
+ if (typeof message?.name === 'string') out.name = message.name;
430
+ if (typeof message?.tool_call_id === 'string') out.tool_call_id = message.tool_call_id;
431
+
432
+ if (Array.isArray(message?.tool_calls)) {
433
+ out.tool_calls = message.tool_calls.map((call) => {
434
+ const raw = String(call?.function?.arguments ?? '{}');
435
+ // ⚠️ The ARGUMENTS of a write_file to `.env` contain the file body. The
436
+ // reply is not the only place a credential lives.
437
+ let args = raw;
438
+ if (isCredentialCall(call)) {
439
+ args = JSON.stringify({ withheld: true });
440
+ redactions += 1;
441
+ } else {
442
+ const r = redactSecrets(raw);
443
+ redactions += r.redactions;
444
+ args = truncate(r.text, maxChars);
445
+ }
446
+ return {
447
+ id: call?.id,
448
+ type: call?.type ?? 'function',
449
+ function: { name: call?.function?.name, arguments: args },
450
+ };
451
+ });
452
+ }
453
+ return { message: out, redactions };
454
+ }
455
+
456
+ function isCredentialCall(call) {
457
+ let args = {};
458
+ try { args = JSON.parse(call?.function?.arguments || '{}'); } catch { return false; }
459
+ return [args.path, args.file, args.filename].some((p) => isCredentialPath(p));
460
+ }
461
+
462
+ /**
463
+ * The full sanitising pass: drop dangling calls, withhold credential contents,
464
+ * redact what is left, cap each message, then cap the WHOLE record by dropping
465
+ * the oldest middle groups.
466
+ *
467
+ * ⚠️ IT DROPS FROM THE MIDDLE, AND THE DIRECTION MATTERS. The head is the task
468
+ * and the workspace shape; the tail is what the model was doing when the budget
469
+ * ran out. Both ends are load-bearing and the middle is the part a resumed run
470
+ * can most afford to re-derive. Dropping the tail to save the middle would
471
+ * produce a session that resumes into work already done.
472
+ *
473
+ * @param {any[]} messages
474
+ * @param {{ maxBytes?: number, maxChars?: number }} [opts]
475
+ */
476
+ export function sanitizeMessages(messages, { maxBytes = MAX_SESSION_BYTES, maxChars = MAX_MESSAGE_CHARS } = {}) {
477
+ if (!Array.isArray(messages) || messages.length === 0) {
478
+ return { messages: [], redactions: 0, droppedGroups: 0, droppedIncomplete: 0, truncated: false };
479
+ }
480
+ const { head, groups } = groupMessages(messages);
481
+ const pruned = dropDanglingCalls(groups);
482
+
483
+ let redactions = 0;
484
+ const cleanHead = head.map((m) => {
485
+ const s = sanitizeMessage(m, { maxChars });
486
+ redactions += s.redactions;
487
+ return s.message;
488
+ });
489
+ const cleanGroups = pruned.groups.map((group) => {
490
+ const withheldIds = credentialCallIds(group);
491
+ return group.map((m) => {
492
+ const withhold = m?.role === 'tool' && withheldIds.has(m.tool_call_id);
493
+ const s = sanitizeMessage(m, { withhold, maxChars });
494
+ redactions += s.redactions;
495
+ return s.message;
496
+ });
497
+ });
498
+
499
+ // Byte cap, enforced by dropping the oldest droppable group and re-measuring.
500
+ // Measuring is cheap next to the alternative (estimating, and being wrong on
501
+ // the one transcript that mattered).
502
+ let kept = cleanGroups;
503
+ let droppedGroups = 0;
504
+ const size = () => JSON.stringify([...cleanHead, ...kept.flat()]).length;
505
+ while (kept.length > 0 && size() > maxBytes) {
506
+ kept = kept.slice(1);
507
+ droppedGroups += 1;
508
+ }
509
+
510
+ const out = [...cleanHead, ...kept.flat()];
511
+ if (droppedGroups > 0) {
512
+ /**
513
+ * ⚠️ SAY THAT THE HOLE IS THERE. A history with a silent gap is a history
514
+ * the model will reason across as if it were continuous — it sees its own
515
+ * earlier message, then a much later one, and infers a step it never took.
516
+ * One user message costs nothing and turns a lie into a known unknown.
517
+ *
518
+ * Inserted after the head, which is exactly where the hole is.
519
+ */
520
+ out.splice(cleanHead.length, 0, {
521
+ role: 'user',
522
+ content: `[${droppedGroups} earlier round${droppedGroups === 1 ? '' : 's'} of this session were dropped to fit the saved-session size limit. `
523
+ + 'Their work may already be on disk — check before redoing anything.]',
524
+ });
525
+ }
526
+
527
+ return {
528
+ messages: out,
529
+ redactions,
530
+ droppedGroups,
531
+ droppedIncomplete: pruned.dropped,
532
+ truncated: droppedGroups > 0,
533
+ };
534
+ }
535
+
536
+ /**
537
+ * Pull the FACTS out of a finished session: what was written, what was run, and
538
+ * what happened when it ran.
539
+ *
540
+ * ⚠️ Read from `outcome.executed`, not from the messages. The tool records are
541
+ * structured; the messages are the model-facing rendering of them, and parsing
542
+ * a rendering back into data is how a summary starts disagreeing with the run.
543
+ */
544
+ function extractActivity(executed) {
545
+ const files = [];
546
+ const commands = [];
547
+ if (!Array.isArray(executed)) return { files, commands };
548
+
549
+ for (const record of executed) {
550
+ const name = record?.name;
551
+ const result = record?.result;
552
+ if (name === 'write_file' || name === 'edit_file' || name === 'delete_file' || name === 'make_document') {
553
+ const path = result?.path ?? record?.args?.path;
554
+ if (typeof path !== 'string') continue;
555
+ if (files.length >= MAX_FILES_RECORDED) continue;
556
+ files.push({
557
+ path,
558
+ action: name === 'delete_file' ? 'deleted' : (result?.created === true ? 'created' : 'changed'),
559
+ ok: result?.ok === true,
560
+ bytes: typeof result?.bytes === 'number' ? result.bytes : undefined,
561
+ });
562
+ continue;
563
+ }
564
+ if (name === 'run_command' || name === 'evaluate') {
565
+ if (commands.length >= MAX_COMMANDS_RECORDED) continue;
566
+ const command = result?.command ?? (name === 'evaluate' ? 'evaluate' : record?.args?.command);
567
+ /**
568
+ * ⚠️ THE OUTPUT IS KEPT, BUT ONLY AS EVIDENCE. A resumed run must be able
569
+ * to see that `npm test` failed and HOW — that is the single most useful
570
+ * thing in the record. It must never be able to mistake it for a fresh
571
+ * result, which is why `at` is dropped and the resume note says plainly
572
+ * that nothing here was re-run.
573
+ */
574
+ const tail = redactSecrets(String(result?.stderr || result?.stdout || '').trim());
575
+ commands.push({
576
+ command: typeof command === 'string' ? command : String(command ?? 'unknown'),
577
+ ok: result?.ok === true,
578
+ passed: result?.passed ?? null,
579
+ exitCode: result?.exitCode ?? null,
580
+ timedOut: result?.timedOut === true,
581
+ output: tail.text ? truncate(tail.text, MAX_COMMAND_OUTPUT_CHARS) : undefined,
582
+ });
583
+ }
584
+ }
585
+ return { files, commands };
586
+ }
587
+
588
+ /**
589
+ * @typedef {{ ok: false, error: string }} SessionRefused
590
+ * @typedef {{ ok: true, id: string, path: string, bytes: number, redactions: number, droppedGroups: number, droppedIncomplete: number, pruned: string[], resumable: boolean }} SessionSaved
591
+ */
592
+
593
+ /**
594
+ * Persist a finished (or abandoned) run.
595
+ *
596
+ * ⚠️ `outcome` may be a FAILURE. `runSession` returns `{ok:false, stage, error}`
597
+ * when round 1 could not reach the model, and that run is still worth a record:
598
+ * it is the one a user is most likely to re-issue, and knowing it died in
599
+ * `gather` rather than mid-work is what stops them re-running it unchanged.
600
+ * A failed session is saved and listable; it is simply not RESUMABLE, and the
601
+ * record says so rather than leaving the caller to infer it.
602
+ *
603
+ * @param {string} root
604
+ * @param {any} outcome the SessionOutcome from turn.mjs
605
+ * @param {{ task?: string, id?: string, now?: Date, keep?: number }} [meta]
606
+ * @returns {SessionSaved | SessionRefused}
607
+ */
608
+ export function saveSession(root, outcome, meta = {}) {
609
+ const id = typeof meta.id === 'string' && meta.id ? meta.id : newSessionId(meta.now ?? new Date());
610
+ if (!ID_SHAPE.test(id)) {
611
+ return { ok: false, error: `session id "${id}" is not the expected YYYYMMDD-HHMMSS-xxxx shape — use newSessionId()` };
612
+ }
613
+ const f = resolveSessionFile(root, `${id}.json`);
614
+ if (!f.ok) return f;
615
+
616
+ const failed = !outcome || outcome.ok !== true;
617
+ const clean = sanitizeMessages(outcome?.messages ?? []);
618
+ const { files, commands } = extractActivity(outcome?.executed);
619
+ const taskText = redactSecrets(String(meta.task ?? '')).text;
620
+
621
+ const record = {
622
+ version: SESSION_VERSION,
623
+ id,
624
+ savedAt: new Date(meta.now ?? Date.now()).toISOString(),
625
+ root: f.root,
626
+ task: truncate(taskText, MAX_TASK_CHARS),
627
+ model: outcome?.model ?? null,
628
+ roundsUsed: typeof outcome?.roundsUsed === 'number' ? outcome.roundsUsed : 0,
629
+ maxRounds: typeof outcome?.maxRounds === 'number' ? outcome.maxRounds : null,
630
+ // ⚠️ `stage` is the honest answer for a failure — "round-cap" would be a
631
+ // lie about a run that never reached round 2.
632
+ stoppedBecause: failed ? `failed:${outcome?.stage ?? 'unknown'}` : String(outcome?.stoppedBecause ?? 'unknown'),
633
+ error: failed ? redactSecrets(String(outcome?.error ?? '')).text || null : null,
634
+ verification: outcome?.verification
635
+ ? { ran: outcome.verification.ran === true, passed: outcome.verification.passed ?? null, command: outcome.verification.command ?? null }
636
+ : null,
637
+ usage: outcome?.usage ?? null,
638
+ files,
639
+ commands,
640
+ // Dropping every message is a legitimate outcome (a failure before round 1)
641
+ // and it is the difference between a listable record and a resumable one.
642
+ resumable: clean.messages.length > 0,
643
+ truncated: clean.truncated,
644
+ droppedGroups: clean.droppedGroups,
645
+ droppedIncomplete: clean.droppedIncomplete,
646
+ redactions: clean.redactions,
647
+ messages: clean.messages,
648
+ };
649
+
650
+ const written = writeRecord(f, record);
651
+ if (!written.ok) return written;
652
+ /**
653
+ * ⚠️ THE SECOND PLACE `.acuvo/` COMES INTO EXISTENCE IN A NORMAL RUN. A run
654
+ * with `--no-audit` but sessions on would otherwise create the directory with
655
+ * nothing to keep it out of the user's `git status` — which is the defect our
656
+ * own bench caught on the `git` task, arriving through a different door.
657
+ *
658
+ * ⭐ AFTER the write, not before: if the session could not be saved there is
659
+ * no directory of ours to hide, and creating one to ignore it would be the
660
+ * litter it exists to prevent.
661
+ */
662
+ ensureAcuvoDirIgnored(root);
663
+ const pruned = pruneSessions(root, { keep: meta.keep ?? MAX_SESSIONS });
664
+
665
+ return {
666
+ ok: true,
667
+ id,
668
+ path: f.relative,
669
+ bytes: written.bytes,
670
+ redactions: clean.redactions,
671
+ droppedGroups: clean.droppedGroups,
672
+ droppedIncomplete: clean.droppedIncomplete,
673
+ pruned: pruned.ok ? pruned.removed : [],
674
+ resumable: record.resumable,
675
+ };
676
+ }
677
+
678
+ /**
679
+ * ⚠️ UNIQUE TEMP NAME, THEN RENAME — the same rule and the same reason as
680
+ * `savePlan`. A fixed `.tmp` shared by two processes is worse than no temp file
681
+ * at all: both write to it and the rename publishes an interleaving of two
682
+ * documents, which then looks like a bug in the parser.
683
+ */
684
+ let tmpCounter = 0;
685
+
686
+ function writeRecord(f, record) {
687
+ const body = `${JSON.stringify(record, null, 2)}\n`;
688
+ const bytes = Buffer.byteLength(body, 'utf8');
689
+ const tmp = `${f.absolute}.${process.pid}-${tmpCounter++}.tmp`;
690
+ try {
691
+ mkdirSync(dirname(f.absolute), { recursive: true });
692
+ writeFileSync(tmp, body, 'utf8');
693
+ renameSync(tmp, f.absolute);
694
+ } catch (e) {
695
+ try { unlinkSync(tmp); } catch { /* nothing to clean up */ }
696
+ return { ok: false, error: `could not write ${f.relative}: ${err(e)}` };
697
+ }
698
+ return { ok: true, bytes };
699
+ }
700
+
701
+ /**
702
+ * ⚠️ VALIDATE THE FILE, DO NOT TRUST IT — `plan-ledger.mjs`'s rule, and it
703
+ * applies harder here because this file is bigger and a half-written one from a
704
+ * killed process is a realistic thing to find. Returns null for anything it does
705
+ * not fully recognise, and null means "treat as absent", never "throw".
706
+ */
707
+ function parseSession(raw) {
708
+ let data;
709
+ try { data = JSON.parse(raw); } catch { return null; }
710
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
711
+ if (data.version !== SESSION_VERSION) return null;
712
+ if (typeof data.id !== 'string' || !ID_SHAPE.test(data.id)) return null;
713
+ if (!Array.isArray(data.messages)) return null;
714
+ if (!Array.isArray(data.files) || !Array.isArray(data.commands)) return null;
715
+ return data;
716
+ }
717
+
718
+ /**
719
+ * @param {string} root
720
+ * @param {string} id
721
+ * @returns {{ ok: true, session: any } | SessionRefused}
722
+ */
723
+ export function loadSession(root, id) {
724
+ const f = resolveSessionFile(root, `${String(id).replace(/\.json$/, '')}.json`);
725
+ if (!f.ok) return f;
726
+ if (!existsSync(f.absolute)) {
727
+ /**
728
+ * ⚠️ AN ERROR STRING IS AN INSTRUCTION. "not found" alone sends the reader
729
+ * back to guess another id; naming the command that lists them ends the
730
+ * guessing in one step.
731
+ */
732
+ return { ok: false, error: `no saved session "${id}" in this workspace — run with --sessions to see the ids that exist` };
733
+ }
734
+ let raw;
735
+ try { raw = readFileSync(f.absolute, 'utf8'); } catch (e) {
736
+ return { ok: false, error: `could not read ${f.relative}: ${err(e)}` };
737
+ }
738
+ const session = parseSession(raw);
739
+ if (!session) {
740
+ return { ok: false, error: `${f.relative} is not a session this version can read — it is corrupt or was written by a newer build. Start a fresh run; the file is harmless where it is.` };
741
+ }
742
+ return { ok: true, session };
743
+ }
744
+
745
+ /**
746
+ * Rebuild the conversation so a follow-up round starts with everything the
747
+ * previous run learned.
748
+ *
749
+ * ⭐ THE RETURNED ARRAY IS EXACTLY WHAT `runSession({ priorMessages })` WANTS.
750
+ * That path already exists for interactive chat — it appends one user message
751
+ * and rebuilds nothing — so resume needs no new plumbing in the loop, which is
752
+ * the whole reason this shape was chosen over a summary string.
753
+ *
754
+ * ⚠️ IT RE-RUNS NOTHING. Every command in the record is history and is labelled
755
+ * as history in the note below. The dangling-call guard has already removed the
756
+ * only structure that could have caused a replay; the note is what stops the
757
+ * model doing it on purpose because it assumed the environment was unchanged.
758
+ *
759
+ * @param {string} root
760
+ * @param {string} id
761
+ * @returns {{ ok: true, id: string, task: string, messages: any[], note: string, rootChanged: boolean, replayed: false } | SessionRefused}
762
+ */
763
+ export function resumeMessages(root, id) {
764
+ const loaded = loadSession(root, id);
765
+ if (!loaded.ok) return loaded;
766
+ const s = loaded.session;
767
+
768
+ if (!s.resumable || s.messages.length === 0) {
769
+ return {
770
+ ok: false,
771
+ error: `session ${s.id} has no saved conversation (it stopped at "${s.stoppedBecause}" before anything was recorded), so there is nothing to resume. Start a fresh run with the same task.`,
772
+ };
773
+ }
774
+
775
+ /**
776
+ * ⚠️ A SESSION IS ONLY VALID IN THE WORKSPACE IT WAS RECORDED IN. Every path
777
+ * in the history is relative to that root, and every file content is a
778
+ * snapshot of that tree. Resuming elsewhere is not refused — a directory can
779
+ * legitimately be moved or renamed — but it is ANNOUNCED, because a model
780
+ * that believes it already read `src/index.js` when it read a different
781
+ * project's `src/index.js` produces confident nonsense.
782
+ */
783
+ const rootChanged = typeof s.root === 'string' && s.root !== '' && normalizeRoot(s.root) !== normalizeRoot(root);
784
+
785
+ const bits = [
786
+ `Resuming a previous session (${s.id}, saved ${s.savedAt}).`,
787
+ `It used ${s.roundsUsed}${s.maxRounds ? ` of ${s.maxRounds}` : ''} round${s.roundsUsed === 1 ? '' : 's'} and stopped because: ${s.stoppedBecause}.`,
788
+ 'Everything above this line ALREADY HAPPENED — the files were written and the commands were run in that earlier process.',
789
+ 'Nothing has been re-run for you now. Treat the tool results above as a record, not as fresh output:',
790
+ 'if a command mattered and the code has changed since, run it again yourself.',
791
+ ];
792
+ if (s.files.length > 0) {
793
+ bits.push(`Files it touched: ${s.files.slice(0, 12).map((f) => f.path).join(', ')}${s.files.length > 12 ? `, +${s.files.length - 12} more` : ''}.`);
794
+ }
795
+ if (s.droppedIncomplete > 0) {
796
+ bits.push('Its last round was incomplete and has been discarded, so the final tool call it started never finished.');
797
+ }
798
+ if (s.truncated) {
799
+ bits.push('Some middle rounds were dropped to fit the session size limit; work may exist on disk that is not in this history.');
800
+ }
801
+ if (rootChanged) {
802
+ bits.push(`⚠️ This session was recorded in a DIFFERENT workspace (${s.root}). Paths and file contents above may not describe the tree you are in — verify before trusting them.`);
803
+ }
804
+ const note = bits.join(' ');
805
+
806
+ /**
807
+ * The note rides as a `user` message rather than a `system` one on purpose:
808
+ * `runSession` treats a continuing turn's message list as opaque and appends
809
+ * the new task after it, so a second system message in the middle would sit
810
+ * behind the first one's rules and read as an override attempt. A user turn
811
+ * is what it actually is — the operator saying "here is where we left off".
812
+ */
813
+ return {
814
+ ok: true,
815
+ id: s.id,
816
+ task: s.task,
817
+ messages: [...s.messages, { role: 'user', content: note }],
818
+ note,
819
+ rootChanged,
820
+ replayed: false,
821
+ };
822
+ }
823
+
824
+ const normalizeRoot = (p) => (process.platform === 'win32' ? String(p).toLowerCase().replace(/\\/g, '/') : String(p));
825
+
826
+ /**
827
+ * The recent sessions, newest first, one line each.
828
+ *
829
+ * ⚠️ A CORRUPT FILE IS COUNTED, NOT THROWN. The listing is what a user reaches
830
+ * for when something has already gone wrong, so it is the last place that may
831
+ * fail on a bad file. Unreadable entries are reported as a number.
832
+ *
833
+ * @param {string} root
834
+ * @param {{ limit?: number }} [opts]
835
+ */
836
+ export function listSessions(root, { limit = 10 } = {}) {
837
+ const dir = resolveSessionFile(root, 'probe.json');
838
+ if (!dir.ok) return { ok: false, error: dir.error };
839
+ const absDir = dirname(dir.absolute);
840
+ if (!existsSync(absDir)) return { ok: true, sessions: [], unreadable: 0 };
841
+
842
+ let names;
843
+ try { names = readdirSync(absDir); } catch (e) {
844
+ return { ok: false, error: `could not list ${SESSION_DIR}: ${err(e)}` };
845
+ }
846
+ // Lexical order IS chronological — see newSessionId. Newest first.
847
+ const ids = names.filter((n) => n.endsWith('.json') && ID_SHAPE.test(n.slice(0, -5))).sort().reverse();
848
+
849
+ const sessions = [];
850
+ let unreadable = 0;
851
+ for (const name of ids) {
852
+ if (sessions.length >= limit) break;
853
+ let raw;
854
+ try { raw = readFileSync(`${absDir}/${name}`, 'utf8'); } catch { unreadable += 1; continue; }
855
+ const s = parseSession(raw);
856
+ if (!s) { unreadable += 1; continue; }
857
+ sessions.push({
858
+ id: s.id,
859
+ savedAt: s.savedAt,
860
+ task: s.task,
861
+ roundsUsed: s.roundsUsed,
862
+ files: s.files.length,
863
+ commands: s.commands.length,
864
+ stoppedBecause: s.stoppedBecause,
865
+ resumable: s.resumable === true,
866
+ summary: summarizeSession(s),
867
+ });
868
+ }
869
+ return { ok: true, sessions, unreadable };
870
+ }
871
+
872
+ /**
873
+ * One line, and it has to earn its width: the id (which is what you type to
874
+ * resume), when, how far it got, what it produced, and why it stopped. The task
875
+ * is LAST and clipped, because it is the part you already recognise.
876
+ */
877
+ export function summarizeSession(s) {
878
+ const when = String(s.savedAt ?? '').slice(0, 16).replace('T', ' ');
879
+ const parts = [
880
+ s.id,
881
+ when,
882
+ `${s.roundsUsed}r`,
883
+ `${s.files.length} file${s.files.length === 1 ? '' : 's'}`,
884
+ `${s.commands.length} cmd`,
885
+ s.stoppedBecause,
886
+ ];
887
+ if (!s.resumable) parts.push('not resumable');
888
+ const task = String(s.task ?? '').replace(/\s+/g, ' ').trim();
889
+ return `${parts.join(' · ')} — ${task.length > 60 ? `${task.slice(0, 59)}…` : task}`;
890
+ }
891
+
892
+ /**
893
+ * Keep the newest `keep` sessions; delete the rest.
894
+ *
895
+ * ⚠️ IT DELETES THE OLDEST, NOT THE LARGEST, and it never refuses to save the
896
+ * newest. A cap enforced from the wrong end — "the directory is full, your run
897
+ * was not saved" — fails at exactly the moment the feature is needed, and the
898
+ * user finds out a day later when the resume they were counting on is missing.
899
+ *
900
+ * ⚠️ AND IT SWEEPS `.tmp` LEFTOVERS. A process killed between `writeFileSync`
901
+ * and `renameSync` leaves one behind; without this they are the one thing in
902
+ * here that genuinely grows for ever, because they match no id shape and so no
903
+ * other code path will ever look at them again.
904
+ */
905
+ export function pruneSessions(root, { keep = MAX_SESSIONS } = {}) {
906
+ const dir = resolveSessionFile(root, 'probe.json');
907
+ if (!dir.ok) return { ok: false, error: dir.error };
908
+ const absDir = dirname(dir.absolute);
909
+ if (!existsSync(absDir)) return { ok: true, removed: [] };
910
+
911
+ let names;
912
+ try { names = readdirSync(absDir); } catch (e) {
913
+ return { ok: false, error: `could not list ${SESSION_DIR}: ${err(e)}` };
914
+ }
915
+
916
+ const removed = [];
917
+ const ids = names.filter((n) => n.endsWith('.json') && ID_SHAPE.test(n.slice(0, -5))).sort();
918
+ const doomed = keep > 0 ? ids.slice(0, Math.max(0, ids.length - keep)) : ids;
919
+
920
+ for (const name of names) {
921
+ const stale = name.endsWith('.tmp') && olderThanAnHour(`${absDir}/${name}`);
922
+ if (!doomed.includes(name) && !stale) continue;
923
+ try { unlinkSync(`${absDir}/${name}`); removed.push(name); } catch {
924
+ // Locked by another process, or already gone. Pruning is housekeeping —
925
+ // it must never be able to fail a save.
926
+ }
927
+ }
928
+ return { ok: true, removed };
929
+ }
930
+
931
+ /** A `.tmp` younger than an hour may belong to a live process writing right
932
+ * now. Deleting it would be this module racing itself. */
933
+ function olderThanAnHour(path) {
934
+ try { return Date.now() - statSync(path).mtimeMs > 3_600_000; } catch { return false; }
935
+ }
936
+
937
+ /**
938
+ * ⚠️ ONE READ-ONLY TOOL, AND NO RESUME TOOL. See the header: a model that can
939
+ * rewrite its own message history mid-run is the side-effect replay hazard this
940
+ * module is built around. Resume is an OPERATOR action, taken between runs,
941
+ * from the command line. What the model may do is LOOK — knowing that the same
942
+ * task was attempted an hour ago and died at the round cap is context worth
943
+ * having, and it costs one small tool result.
944
+ */
945
+ export function sessionToolSchemas() {
946
+ return [
947
+ {
948
+ type: 'function',
949
+ function: {
950
+ name: 'list_sessions',
951
+ description: [
952
+ 'List recent saved runs of this CLI in this workspace: when each ran, how many rounds it used,',
953
+ 'how many files it wrote, and why it stopped.',
954
+ 'Use it when the task looks like a continuation of earlier work — a previous run may already have',
955
+ 'written some of what you were asked for.',
956
+ 'This is READ-ONLY: it cannot resume anything and it re-runs nothing. To actually continue a',
957
+ 'session the person running this CLI passes --resume <id>.',
958
+ ].join(' '),
959
+ parameters: {
960
+ type: 'object',
961
+ properties: {
962
+ limit: { type: 'integer', description: 'How many to list, newest first. Default 10.' },
963
+ },
964
+ required: [],
965
+ },
966
+ },
967
+ },
968
+ ];
969
+ }
970
+
971
+ /**
972
+ * ── THE REGISTRATION SNIPPET — NOT WIRED IN, ON PURPOSE ─────────────────────
973
+ *
974
+ * Three edits, in three files, none of which this module makes:
975
+ *
976
+ * 1. lib/tools.mjs — offer the read-only tool
977
+ * import { listSessions, sessionToolSchemas } from './session.mjs';
978
+ * …in TOOL_SCHEMAS: ...sessionToolSchemas(),
979
+ * …in executeToolCall: case 'list_sessions':
980
+ * return { id, name, args,
981
+ * result: listSessions(executor.root, { limit: args.limit }),
982
+ * mutated: false };
983
+ *
984
+ * 2. lib/turn.mjs — save at the end of a run. AFTER `releaseMcp()`, before
985
+ * the return, so a save can never keep a child process alive:
986
+ * import { saveSession } from './session.mjs';
987
+ * const saved = saveSession(executor.root, outcome, { task });
988
+ * if (saved.ok) onEvent({ type: 'session', id: saved.id, redactions: saved.redactions });
989
+ * ⚠️ Never let a failed save fail the run — the work is already on disk.
990
+ *
991
+ * 3. bin/acuvo.mjs — the operator surface:
992
+ * --sessions → listSessions(root).sessions.map(s => s.summary)
993
+ * --resume <id> → const r = resumeMessages(root, id);
994
+ * if (!r.ok) { fail(r.error); }
995
+ * runSession({ ..., priorMessages: r.messages });
996
+ * ⚠️ `--resume` with no follow-up task is a valid request ("carry on"), so
997
+ * pass the ORIGINAL task (`r.task`) when the user gives no new one.
998
+ */
999
+ export const REGISTRATION_SNIPPET = `// lib/tools.mjs
1000
+ import { listSessions, sessionToolSchemas } from './session.mjs';
1001
+ export const TOOL_SCHEMAS = [ /* … */ ...sessionToolSchemas() ];
1002
+ // in executeToolCall's switch:
1003
+ case 'list_sessions':
1004
+ return { id, name, args, result: listSessions(executor.root, { limit: args.limit }), mutated: false };
1005
+
1006
+ // lib/turn.mjs — after releaseMcp(), before the return
1007
+ import { saveSession } from './session.mjs';
1008
+ const saved = saveSession(executor.root, outcome, { task });
1009
+ if (saved.ok) onEvent({ type: 'session', id: saved.id, redactions: saved.redactions });
1010
+
1011
+ // bin/acuvo.mjs
1012
+ if (flags.sessions) for (const s of listSessions(root).sessions) console.log(s.summary);
1013
+ if (flags.resume) {
1014
+ const r = resumeMessages(root, flags.resume);
1015
+ if (!r.ok) return fail(r.error);
1016
+ await runSession({ ...opts, task: task || r.task, priorMessages: r.messages });
1017
+ }`;