acuvo-code 0.6.1 → 0.6.3
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/bin/acuvo.mjs +172 -6
- package/lib/chat.mjs +7 -1
- package/lib/input-box.mjs +46 -2
- package/lib/session.mjs +301 -11
- package/lib/turn.mjs +60 -0
- package/package.json +1 -1
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';
|
|
@@ -1612,6 +1612,84 @@ ${formatBoard(listed)}
|
|
|
1612
1612
|
process.on('exit', () => { try { if (claimed?.lease) releaseAll([claimed.lease]); } catch { /* exiting anyway */ } });
|
|
1613
1613
|
}
|
|
1614
1614
|
|
|
1615
|
+
/**
|
|
1616
|
+
* ── ⭐⭐⭐ "IF MY LAPTOP CRASHES, THE CHAT HISTORY IS GONE" ─────────────────
|
|
1617
|
+
*
|
|
1618
|
+
* ⚠️ IT WAS. MEASURED 2026-08-22: a run killed with SIGKILL after two
|
|
1619
|
+
* completed rounds and two files written left `.acuvo/sessions/` NON-EXISTENT.
|
|
1620
|
+
* `saveSession` only ever ran after the loop, so the run you most want back
|
|
1621
|
+
* was the only kind that left nothing behind. The checkpoint wiring in
|
|
1622
|
+
* `oneTurn` fixed the WRITE half; this is the READ half, and without it the
|
|
1623
|
+
* recovery only exists for someone who already knows to type `--continue` —
|
|
1624
|
+
* which is not the person who just lost their work.
|
|
1625
|
+
*
|
|
1626
|
+
* ⚠️⚠️ IT MUST NEVER FIRE FOR A RUN THAT IS STILL GOING. Seven terminals in
|
|
1627
|
+
* one workspace is the documented normal case for this tool, and each one
|
|
1628
|
+
* holds an open live record. `findCrashedSession` refuses any record whose pid
|
|
1629
|
+
* still answers — see its header for the other two conditions.
|
|
1630
|
+
*
|
|
1631
|
+
* ⭐ ACCEPTING IT REUSES `--resume` WHOLE. The offer sets an id and the
|
|
1632
|
+
* existing block below does the rest, so the restored conversation, the sticky
|
|
1633
|
+
* routing key and the budget subtraction are the same code on both doors. A
|
|
1634
|
+
* second copy of that block is how one of the two would end up without the
|
|
1635
|
+
* budget guard.
|
|
1636
|
+
*
|
|
1637
|
+
* ⚠️ THE PROMPT IS TTY-ONLY. A CI job, a `| jq` pipeline or a cron entry must
|
|
1638
|
+
* never block on a question nobody is there to answer — those get the lines
|
|
1639
|
+
* and the command, and carry on with the fresh run they asked for.
|
|
1640
|
+
*/
|
|
1641
|
+
let crashOfferId = null;
|
|
1642
|
+
/**
|
|
1643
|
+
* ⚠️ `opts.bestOf < 2` FOR THE SAME REASON `--best-of` REFUSES `--resume`
|
|
1644
|
+
* outright: it forks the task into independent attempts and keeps one, so
|
|
1645
|
+
* accepting a restored conversation here would silently discard the history
|
|
1646
|
+
* the user had just said yes to. Offering something we would then throw away
|
|
1647
|
+
* is worse than not offering.
|
|
1648
|
+
*/
|
|
1649
|
+
if (!resumeRequested && !opts.parallel && opts.issue === null && !opts.dryRun && life.save && opts.bestOf < 2) {
|
|
1650
|
+
let found = { ok: false };
|
|
1651
|
+
try { found = findCrashedSession(root); } catch { /* a recovery hint may never break a run */ }
|
|
1652
|
+
if (found.ok && found.crashed) {
|
|
1653
|
+
const say = (t) => (opts.json ? process.stderr : process.stdout).write(t);
|
|
1654
|
+
say(`${crashOfferLines(found.crashed).join('\n')}\n`);
|
|
1655
|
+
const askable = process.stdin.isTTY === true && process.stdout.isTTY === true && !opts.json;
|
|
1656
|
+
if (askable) {
|
|
1657
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
1658
|
+
const answer = await new Promise((r) => rl.question(' continue that conversation instead of starting fresh? [Y/n] ', (l) => { rl.close(); r(l); }));
|
|
1659
|
+
/**
|
|
1660
|
+
* ⚠️ ENTER MEANS YES HERE, WHICH IS THE OPPOSITE OF `--task-audio`'s
|
|
1661
|
+
* default, and the difference is deliberate: that prompt guards an
|
|
1662
|
+
* ACTION taken on a possibly mis-heard instruction, so silence must
|
|
1663
|
+
* cancel. This one guards CONTEXT the user already paid for, and
|
|
1664
|
+
* nothing is executed by restoring it — the expensive mistake is
|
|
1665
|
+
* throwing the conversation away, not keeping it.
|
|
1666
|
+
*/
|
|
1667
|
+
if (!/^\s*n(o)?\s*$/i.test(answer)) crashOfferId = found.crashed.id;
|
|
1668
|
+
else say(' starting fresh. That run stays on disk — `acuvo --sessions` lists it.\n');
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* ⚠️⚠️ ANSWERED IS ANSWERED — INCLUDING "I ONLY PRINTED IT". The marker
|
|
1672
|
+
* lives in the record, so without this line the same warning fires on
|
|
1673
|
+
* every subsequent run in this workspace, for ever, and a warning that
|
|
1674
|
+
* fires when nothing is wrong is one people learn to read past. That would
|
|
1675
|
+
* cost the real one.
|
|
1676
|
+
*
|
|
1677
|
+
* ⚠️ NOT ON ACCEPT, AND THE ORDER IS THE REASON. `resumeMessages` reads
|
|
1678
|
+
* `closedCleanly` to tell the model "that run was KILLED mid-round, its
|
|
1679
|
+
* last round may be missing but its work may be on disk". Closing the
|
|
1680
|
+
* record here would erase that sentence a few lines before it is written.
|
|
1681
|
+
* The resume block below marks it once it has been read.
|
|
1682
|
+
*
|
|
1683
|
+
* ⭐ NOTHING IS DELETED EITHER WAY. The record stays listable, replayable
|
|
1684
|
+
* and `--resume <id>`-able; it just stops volunteering — exactly what the
|
|
1685
|
+
* decline message above promises.
|
|
1686
|
+
*/
|
|
1687
|
+
if (!crashOfferId) {
|
|
1688
|
+
try { markSessionClosed(root, found.crashed.id); } catch { /* the offer already did its job */ }
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1615
1693
|
let priorMessages = null;
|
|
1616
1694
|
/**
|
|
1617
1695
|
* ── ⭐⭐⭐ ONE STICKY KEY FOR THIS WHOLE CONVERSATION, ACROSS PROCESSES ────
|
|
@@ -1629,7 +1707,7 @@ ${formatBoard(listed)}
|
|
|
1629
1707
|
* the conversation being resumed had already paid to build.
|
|
1630
1708
|
*/
|
|
1631
1709
|
let stickyKey = `acuvo-${randomUUID()}`;
|
|
1632
|
-
if (resumeRequested) {
|
|
1710
|
+
if (resumeRequested || crashOfferId) {
|
|
1633
1711
|
if (life.resume !== null && life.continueLatest) {
|
|
1634
1712
|
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
1713
|
}
|
|
@@ -1640,7 +1718,10 @@ ${formatBoard(listed)}
|
|
|
1640
1718
|
die('--issue starts a fresh branch and a fresh conversation, so there is nothing to resume. Drop one of --issue / --resume.', EXIT_USAGE);
|
|
1641
1719
|
}
|
|
1642
1720
|
|
|
1643
|
-
|
|
1721
|
+
// ⚠️ `crashOfferId` is only ever set when NEITHER flag was given (the guard
|
|
1722
|
+
// above requires `!resumeRequested`), so this cannot silently outrank a
|
|
1723
|
+
// `--resume <id>` the user typed.
|
|
1724
|
+
let id = life.resume ?? crashOfferId;
|
|
1644
1725
|
if (life.continueLatest) {
|
|
1645
1726
|
const listed = listSessions(root, { limit: 50 });
|
|
1646
1727
|
if (!listed.ok) die(listed.error, EXIT_FAILED);
|
|
@@ -1663,6 +1744,16 @@ ${formatBoard(listed)}
|
|
|
1663
1744
|
// ⭐ The saved id IS the conversation, so it is the routing key too. This
|
|
1664
1745
|
// line is what makes stickiness survive closing the terminal.
|
|
1665
1746
|
stickyKey = `acuvo-${resumed.id ?? id}`;
|
|
1747
|
+
/**
|
|
1748
|
+
* ⚠️ AFTER `resumeMessages`, NEVER BEFORE — it reads the crash marker to
|
|
1749
|
+
* tell the model that run was killed mid-round. Carrying the conversation
|
|
1750
|
+
* forward is what closes the book on the old record: this run now owns the
|
|
1751
|
+
* history, so the old one must stop announcing itself as unfinished on every
|
|
1752
|
+
* future `acuvo` in this workspace.
|
|
1753
|
+
*/
|
|
1754
|
+
if (resumed.crashed) {
|
|
1755
|
+
try { markSessionClosed(root, resumed.id ?? id); } catch { /* the resume already succeeded */ }
|
|
1756
|
+
}
|
|
1666
1757
|
if (!task) task = resumed.task;
|
|
1667
1758
|
if (!task) {
|
|
1668
1759
|
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 +1763,24 @@ ${formatBoard(listed)}
|
|
|
1672
1763
|
(opts.json ? process.stderr : process.stdout).write(
|
|
1673
1764
|
` · resuming ${resumed.id} — ${priorMessages.length} messages restored, nothing re-run${warn}\n`,
|
|
1674
1765
|
);
|
|
1766
|
+
/**
|
|
1767
|
+
* ── ⚠️ SAY IT WHEN THE DISCOUNT IS GONE ──────────────────────────────────
|
|
1768
|
+
*
|
|
1769
|
+
* The first two messages ARE the cacheable prefix. A record whose head was
|
|
1770
|
+
* clipped resumes into a prompt that differs from the original at message
|
|
1771
|
+
* ZERO, so prefix caching misses on every token — measured at 60.8%
|
|
1772
|
+
* byte-identical before `MAX_HEAD_CHARS` gave the head its own ceiling, i.e.
|
|
1773
|
+
* the whole restored conversation re-bought at full price.
|
|
1774
|
+
*
|
|
1775
|
+
* ⚠️ It is silent on every ordinary resume, which is what makes it worth
|
|
1776
|
+
* printing at all: this fires only on a head above 60,000 characters, and
|
|
1777
|
+
* somebody seeing it needs to know the resume is honest but not cheap.
|
|
1778
|
+
*/
|
|
1779
|
+
if (resumed.headTruncated) {
|
|
1780
|
+
(opts.json ? process.stderr : process.stdout).write(
|
|
1781
|
+
' ⚠ 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',
|
|
1782
|
+
);
|
|
1783
|
+
}
|
|
1675
1784
|
|
|
1676
1785
|
/**
|
|
1677
1786
|
* ── ⚠️⚠️ A RESUMED RUN USED TO GET A WHOLE FRESH BUDGET ──────────────────
|
|
@@ -1845,6 +1954,22 @@ ${formatBoard(listed)}
|
|
|
1845
1954
|
},
|
|
1846
1955
|
});
|
|
1847
1956
|
|
|
1957
|
+
/**
|
|
1958
|
+
* ── ⭐⭐⭐ THE ID THIS TURN WILL BE SAVED UNDER, DECIDED BEFORE IT STARTS ──
|
|
1959
|
+
*
|
|
1960
|
+
* ⚠️ MEASURED, WHICH IS WHY IT IS HERE: a run SIGKILLed mid-round left NO
|
|
1961
|
+
* `.acuvo/sessions/` directory at all — every save happened after the loop,
|
|
1962
|
+
* so the run whose conversation you would most want back was the only kind
|
|
1963
|
+
* that left none. The checkpoints below write the same record the end of the
|
|
1964
|
+
* turn writes, under this id, marked `live` until the turn closes it.
|
|
1965
|
+
*
|
|
1966
|
+
* ⚠️ `--dry-run` AND `--no-session` MUST BE HONOURED HERE TOO, not only in
|
|
1967
|
+
* `persistRun`. A dry run that scattered eight session files while promising
|
|
1968
|
+
* to "touch nothing" is the same broken promise, arrived at from a new door.
|
|
1969
|
+
*/
|
|
1970
|
+
const turnSessionId = newSessionId();
|
|
1971
|
+
const checkpointing = life.save && !opts.dryRun && !over.quiet;
|
|
1972
|
+
|
|
1848
1973
|
let result;
|
|
1849
1974
|
try {
|
|
1850
1975
|
result = await runSession({
|
|
@@ -1947,6 +2072,26 @@ ${formatBoard(listed)}
|
|
|
1947
2072
|
untilDone: opts.untilDone,
|
|
1948
2073
|
// ⭐ The admin layer reaches the loop. OPEN_POLICY when no file exists.
|
|
1949
2074
|
policy,
|
|
2075
|
+
/**
|
|
2076
|
+
* ── ⭐⭐⭐ THE WIRE THAT MAKES A KILLED RUN RECOVERABLE ──────────────────
|
|
2077
|
+
*
|
|
2078
|
+
* `runSession` calls this at every round boundary with the same shape it
|
|
2079
|
+
* returns at the end, so `saveSession` is the whole implementation —
|
|
2080
|
+
* there is one definition of a session record, not a live one and a final
|
|
2081
|
+
* one that drift apart the first time either grows a field.
|
|
2082
|
+
*
|
|
2083
|
+
* ⚠️ IT SWALLOWS ITS OWN FAILURES ON PURPOSE, and this is the one place in
|
|
2084
|
+
* this file that does so silently. `persistRun` announces a failed save
|
|
2085
|
+
* because that is the last word on a finished run; announcing a failed
|
|
2086
|
+
* CHECKPOINT would print the same line once per round for the rest of a
|
|
2087
|
+
* long run, which is how people learn to read past the line that matters.
|
|
2088
|
+
* The end-of-turn save hits the same disk and will say so.
|
|
2089
|
+
*/
|
|
2090
|
+
onCheckpoint: checkpointing
|
|
2091
|
+
? (partial) => {
|
|
2092
|
+
try { saveSession(root, partial, { task: turnTask, id: turnSessionId, live: true }); } catch { /* the work outranks the record */ }
|
|
2093
|
+
}
|
|
2094
|
+
: null,
|
|
1950
2095
|
// ⚠️ STREAMED, NOT BUFFERED. A bounded loop that prints only at the end is
|
|
1951
2096
|
// indistinguishable from a hang for however long it takes, and the whole
|
|
1952
2097
|
// value of watching a fix land is watching it land.
|
|
@@ -2080,7 +2225,13 @@ ${formatBoard(listed)}
|
|
|
2080
2225
|
*/
|
|
2081
2226
|
gate.dispose();
|
|
2082
2227
|
}
|
|
2083
|
-
|
|
2228
|
+
/**
|
|
2229
|
+
* ⚠️ THE SAME ID THE CHECKPOINTS USED — this call is what flips
|
|
2230
|
+
* `closedCleanly` to true. Passing a fresh id here would leave the live
|
|
2231
|
+
* record open forever, and the next `acuvo` in this workspace would offer to
|
|
2232
|
+
* recover a run that finished perfectly well.
|
|
2233
|
+
*/
|
|
2234
|
+
persistRun(turnTask, result, turnSessionId);
|
|
2084
2235
|
/**
|
|
2085
2236
|
* ── ⭐ `--say` — NARRATE THE VERDICT ──────────────────────────────────────
|
|
2086
2237
|
*
|
|
@@ -2216,11 +2367,26 @@ ${formatBoard(listed)}
|
|
|
2216
2367
|
* dry run that creates two files in the workspace has broken that promise
|
|
2217
2368
|
* to save a record of a run that did not happen.
|
|
2218
2369
|
*/
|
|
2219
|
-
|
|
2370
|
+
/**
|
|
2371
|
+
* ── ⭐⭐⭐ ONE ID PER TURN, SHARED BY THE LIVE SAVES AND THE FINAL ONE ──────
|
|
2372
|
+
*
|
|
2373
|
+
* ⚠️ WITHOUT THIS THE CRASH RECOVERY WOULD LITTER. `saveSession` mints a fresh
|
|
2374
|
+
* id whenever it is not given one, so checkpointing each round would leave
|
|
2375
|
+
* eight files for one turn and the finished record would be a ninth — and the
|
|
2376
|
+
* eight orphans would all still carry `closedCleanly: false`, so every one of
|
|
2377
|
+
* them would look like a crash to the startup check. Handing the same id to
|
|
2378
|
+
* every write means one file per turn, exactly as before, whose only new
|
|
2379
|
+
* content is a boolean that flips true when the turn ends.
|
|
2380
|
+
*
|
|
2381
|
+
* ⚠️ MINTED PER TURN, NOT PER PROCESS: interactive mode runs many turns and
|
|
2382
|
+
* has always written one record each. Sharing one id across a conversation
|
|
2383
|
+
* would collapse the whole history into a single overwritten file.
|
|
2384
|
+
*/
|
|
2385
|
+
const persistRun = (turnTask, result, id = null) => {
|
|
2220
2386
|
if (opts.dryRun) return;
|
|
2221
2387
|
if (life.save) {
|
|
2222
2388
|
try {
|
|
2223
|
-
const saved = saveSession(root, result, { task: turnTask });
|
|
2389
|
+
const saved = saveSession(root, result, { task: turnTask, ...(id ? { id } : {}) });
|
|
2224
2390
|
if (!saved.ok) process.stderr.write(` · the run was not saved: ${saved.error}\n`);
|
|
2225
2391
|
} catch (e) {
|
|
2226
2392
|
process.stderr.write(` · the run was not saved: ${e?.message ?? e}\n`);
|
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 (;;) {
|
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.
|
|
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 };
|
|
@@ -528,7 +553,26 @@ export function pinRegion(output, { rows = 2, env = process.env } = {}) {
|
|
|
528
553
|
* screen. Both are needed and the order matters: erase the history, erase the
|
|
529
554
|
* screen, then home.
|
|
530
555
|
*/
|
|
531
|
-
|
|
556
|
+
/**
|
|
557
|
+
* ── ⚠️⚠️ THE CANONICAL CLEAR, IN THE CANONICAL ORDER — TWICE WRONG BEFORE ──
|
|
558
|
+
*
|
|
559
|
+
* Roman, twice: *"you have to scroll down to see the prompt area."*
|
|
560
|
+
*
|
|
561
|
+
* What `clear` itself emits is `ESC[H ESC[2J ESC[3J` — HOME FIRST, then erase
|
|
562
|
+
* the screen, then erase the scrollback. I had `3J 2J` with the home last, and
|
|
563
|
+
* the order is not cosmetic: erasing scrollback while the cursor is still
|
|
564
|
+
* parked in it leaves the viewport anchored to a region that no longer exists,
|
|
565
|
+
* so the terminal keeps showing the old shell output and our banner and prompt
|
|
566
|
+
* sit below the fold.
|
|
567
|
+
*
|
|
568
|
+
* ⭐ HOME FIRST puts the viewport at the top of the buffer BEFORE anything is
|
|
569
|
+
* erased, so there is nowhere stale for it to stay.
|
|
570
|
+
*
|
|
571
|
+
* ⚠️ AND THE SCROLL REGION IS SET LAST. Setting a region while the cursor is
|
|
572
|
+
* outside it is undefined across terminals — some clamp, some ignore it. Clear
|
|
573
|
+
* completely, then declare the region, then place the cursor inside it.
|
|
574
|
+
*/
|
|
575
|
+
output.write(`${CSI}H${CSI}2J${CSI}3J${CSI}1;${bottom}r${CSI}1;1H`);
|
|
532
576
|
|
|
533
577
|
const release = () => {
|
|
534
578
|
if (released) return;
|
package/lib/session.mjs
CHANGED
|
@@ -83,7 +83,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync,
|
|
|
83
83
|
import { dirname } from 'node:path';
|
|
84
84
|
|
|
85
85
|
import { resolveInWorkspace } from './workspace.mjs';
|
|
86
|
-
import { refusedCommitPath } from './secret-paths.mjs';
|
|
86
|
+
import { refusedCommitPath } from './secret-paths.mjs';
|
|
87
87
|
import { ensureAcuvoDirIgnored } from './acuvo-dir.mjs';
|
|
88
88
|
|
|
89
89
|
/** Scratch, alongside `plan.json`, `mcp.json` and the screenshots `see_page`
|
|
@@ -101,6 +101,43 @@ export const MAX_SESSIONS = 20;
|
|
|
101
101
|
/** Per-message ceiling. A single `read_file` of a 200KB source file would
|
|
102
102
|
* otherwise be most of the budget on its own. */
|
|
103
103
|
export const MAX_MESSAGE_CHARS = 8_000;
|
|
104
|
+
/**
|
|
105
|
+
* ── ⚠️⚠️ THE HEAD GETS ITS OWN CEILING, AND THE OLD ONE WAS A CACHE BUG ─────
|
|
106
|
+
*
|
|
107
|
+
* MEASURED 2026-08-22 on a real run against a stub provider, not reasoned about:
|
|
108
|
+
*
|
|
109
|
+
* live system message 12,118 chars
|
|
110
|
+
* saved system message 8,117 chars ← MAX_MESSAGE_CHARS + the truncation note
|
|
111
|
+
*
|
|
112
|
+
* `MAX_MESSAGE_CHARS` was applied to EVERY message including the system one, so
|
|
113
|
+
* a `--resume` handed the provider a prompt that diverged from the original at
|
|
114
|
+
* **character 8,121 of message zero**. Measured end to end (original round-2
|
|
115
|
+
* payload vs resumed round-1 payload): the byte-identical common prefix was
|
|
116
|
+
* **60.8%**, and prefix caching matches from the first token and stops at the
|
|
117
|
+
* first difference — so the divergence being inside message 0 means the WHOLE
|
|
118
|
+
* restored conversation was re-bought at full price. `lib/chat.mjs` puts the
|
|
119
|
+
* value of that prefix at 97.2% cached / 4.3x cheaper; this threw all of it
|
|
120
|
+
* away, silently, on the one code path whose entire justification is not paying
|
|
121
|
+
* twice for work already done.
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ AND THE MONEY IS THE SMALLER HALF. The 4,118 characters that fell off the
|
|
124
|
+
* end of that system message were the SKILLS BLOCK — so a resumed model was
|
|
125
|
+
* reasoning under rules it had never been shown, which is exactly the failure
|
|
126
|
+
* `compact.mjs`'s header names ("the resumed model starts reasoning about
|
|
127
|
+
* instructions it was never given").
|
|
128
|
+
*
|
|
129
|
+
* ⭐ SO THE HEAD IS CAPPED SEPARATELY AND GENEROUSLY. It is bounded by
|
|
130
|
+
* construction anyway — the system message is a prompt this package assembles,
|
|
131
|
+
* and the first user message is `repo-map.mjs`'s output, which has its own
|
|
132
|
+
* budget. 60,000 is ~5x the largest head observed and still a quarter of
|
|
133
|
+
* MAX_SESSION_BYTES, so the file cap below stays the real bound.
|
|
134
|
+
*
|
|
135
|
+
* ⚠️ IT IS A CAP, NOT AN EXEMPTION. A head that somehow exceeds it is still
|
|
136
|
+
* truncated rather than allowed to blow the file limit — but the record then
|
|
137
|
+
* carries `headTruncated: true` so `resumeMessages` can SAY the cache will miss
|
|
138
|
+
* instead of letting someone believe they got the discount.
|
|
139
|
+
*/
|
|
140
|
+
export const MAX_HEAD_CHARS = 60_000;
|
|
104
141
|
export const MAX_TASK_CHARS = 400;
|
|
105
142
|
/** The metadata lists are for a HUMAN reading `--sessions`, so they are short
|
|
106
143
|
* by intent — the full detail is in the messages. */
|
|
@@ -399,23 +436,68 @@ const WITHHELD =
|
|
|
399
436
|
'[withheld: this tool call touched a credential file, so its contents were not saved with the session. '
|
|
400
437
|
+ 'Read the file again in this run if you need it.]';
|
|
401
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
|
+
|
|
402
484
|
/**
|
|
403
485
|
* Scrub and cap one message. Returns a NEW object — the caller's array belongs
|
|
404
486
|
* to a live session that may still be in use, and mutating it here would edit
|
|
405
487
|
* the conversation a running loop is about to send.
|
|
406
488
|
*/
|
|
407
489
|
function sanitizeMessage(message, { withhold = false, maxChars = MAX_MESSAGE_CHARS } = {}) {
|
|
408
|
-
const
|
|
490
|
+
const value = {};
|
|
409
491
|
let redactions = 0;
|
|
410
492
|
|
|
411
493
|
if (typeof message?.content === 'string') {
|
|
412
494
|
if (withhold) {
|
|
413
|
-
|
|
495
|
+
value.content = WITHHELD;
|
|
414
496
|
redactions += 1;
|
|
415
497
|
} else {
|
|
416
498
|
const r = redactSecrets(message.content);
|
|
417
499
|
redactions += r.redactions;
|
|
418
|
-
|
|
500
|
+
value.content = truncate(r.text, maxChars);
|
|
419
501
|
}
|
|
420
502
|
} else if (message?.content !== undefined) {
|
|
421
503
|
// Non-string content (an array of parts, from a multimodal round). Keep the
|
|
@@ -423,14 +505,14 @@ function sanitizeMessage(message, { withhold = false, maxChars = MAX_MESSAGE_CHA
|
|
|
423
505
|
// assistant message is another way to earn a 400.
|
|
424
506
|
const r = redactSecrets(JSON.stringify(message.content));
|
|
425
507
|
redactions += r.redactions;
|
|
426
|
-
|
|
508
|
+
value.content = truncate(r.text, maxChars);
|
|
427
509
|
}
|
|
428
510
|
|
|
429
|
-
if (typeof message?.name === 'string')
|
|
430
|
-
if (typeof message?.tool_call_id === 'string')
|
|
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;
|
|
431
513
|
|
|
432
514
|
if (Array.isArray(message?.tool_calls)) {
|
|
433
|
-
|
|
515
|
+
value.tool_calls = message.tool_calls.map((call) => {
|
|
434
516
|
const raw = String(call?.function?.arguments ?? '{}');
|
|
435
517
|
// ⚠️ The ARGUMENTS of a write_file to `.env` contain the file body. The
|
|
436
518
|
// reply is not the only place a credential lives.
|
|
@@ -450,6 +532,12 @@ function sanitizeMessage(message, { withhold = false, maxChars = MAX_MESSAGE_CHA
|
|
|
450
532
|
};
|
|
451
533
|
});
|
|
452
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
|
+
}
|
|
453
541
|
return { message: out, redactions };
|
|
454
542
|
}
|
|
455
543
|
|
|
@@ -475,15 +563,25 @@ function isCredentialCall(call) {
|
|
|
475
563
|
*/
|
|
476
564
|
export function sanitizeMessages(messages, { maxBytes = MAX_SESSION_BYTES, maxChars = MAX_MESSAGE_CHARS } = {}) {
|
|
477
565
|
if (!Array.isArray(messages) || messages.length === 0) {
|
|
478
|
-
return { messages: [], redactions: 0, droppedGroups: 0, droppedIncomplete: 0, truncated: false };
|
|
566
|
+
return { messages: [], redactions: 0, droppedGroups: 0, droppedIncomplete: 0, truncated: false, headTruncated: false };
|
|
479
567
|
}
|
|
480
568
|
const { head, groups } = groupMessages(messages);
|
|
481
569
|
const pruned = dropDanglingCalls(groups);
|
|
482
570
|
|
|
483
571
|
let redactions = 0;
|
|
572
|
+
/**
|
|
573
|
+
* ⭐ THE HEAD IS THE CACHEABLE PREFIX, so it is capped at MAX_HEAD_CHARS and
|
|
574
|
+
* not at the per-message ceiling — see MAX_HEAD_CHARS for the measurement that
|
|
575
|
+
* forced this apart. `headTruncated` is reported rather than hidden: a resume
|
|
576
|
+
* whose first message was clipped cannot hit the prompt cache, and the person
|
|
577
|
+
* paying for it is entitled to know that before the bill.
|
|
578
|
+
*/
|
|
579
|
+
let headTruncated = false;
|
|
580
|
+
const headCap = Math.max(maxChars, MAX_HEAD_CHARS);
|
|
484
581
|
const cleanHead = head.map((m) => {
|
|
485
|
-
const s = sanitizeMessage(m, { maxChars });
|
|
582
|
+
const s = sanitizeMessage(m, { maxChars: headCap });
|
|
486
583
|
redactions += s.redactions;
|
|
584
|
+
if (typeof m?.content === 'string' && m.content.length > headCap) headTruncated = true;
|
|
487
585
|
return s.message;
|
|
488
586
|
});
|
|
489
587
|
const cleanGroups = pruned.groups.map((group) => {
|
|
@@ -530,6 +628,7 @@ export function sanitizeMessages(messages, { maxBytes = MAX_SESSION_BYTES, maxCh
|
|
|
530
628
|
droppedGroups,
|
|
531
629
|
droppedIncomplete: pruned.dropped,
|
|
532
630
|
truncated: droppedGroups > 0,
|
|
631
|
+
headTruncated,
|
|
533
632
|
};
|
|
534
633
|
}
|
|
535
634
|
|
|
@@ -600,9 +699,30 @@ function extractActivity(executed) {
|
|
|
600
699
|
* A failed session is saved and listable; it is simply not RESUMABLE, and the
|
|
601
700
|
* record says so rather than leaving the caller to infer it.
|
|
602
701
|
*
|
|
702
|
+
* ── ⭐⭐⭐ `meta.live` — THE HALF THAT SURVIVES A LAPTOP DYING ────────────────
|
|
703
|
+
*
|
|
704
|
+
* MEASURED 2026-08-22 before this existed: a run was SIGKILLed mid-round after
|
|
705
|
+
* two completed rounds and two files written, and `.acuvo/sessions/` **did not
|
|
706
|
+
* exist at all**. Not empty — absent. The checkpoint journal survived (it is
|
|
707
|
+
* appended per write), so the FILES could be put back and the CONVERSATION that
|
|
708
|
+
* produced them was gone. Every save happened at the end of a turn, so the one
|
|
709
|
+
* run you would most want to carry on — the one that died — was the only kind
|
|
710
|
+
* that left nothing to carry on from.
|
|
711
|
+
*
|
|
712
|
+
* ⭐ A live save is the same record, written early and rewritten as the run
|
|
713
|
+
* goes. `closedCleanly: false` is the crash marker; the final save at the end of
|
|
714
|
+
* the turn passes the SAME id and flips it true. So there is exactly one file
|
|
715
|
+
* per turn, exactly as before, and the only new state on disk is one boolean.
|
|
716
|
+
*
|
|
717
|
+
* ⚠️ `pid` IS RECORDED BECAUSE "NOT CLOSED" AND "STILL RUNNING" LOOK IDENTICAL
|
|
718
|
+
* ON DISK. Seven terminals in one workspace is the documented normal case here;
|
|
719
|
+
* offering to resume a conversation another live process is in the middle of
|
|
720
|
+
* would be worse than never offering at all. `findCrashedSession` refuses any
|
|
721
|
+
* record whose pid still answers.
|
|
722
|
+
*
|
|
603
723
|
* @param {string} root
|
|
604
724
|
* @param {any} outcome the SessionOutcome from turn.mjs
|
|
605
|
-
* @param {{ task?: string, id?: string, now?: Date, keep?: number }} [meta]
|
|
725
|
+
* @param {{ task?: string, id?: string, now?: Date, keep?: number, live?: boolean, pid?: number }} [meta]
|
|
606
726
|
* @returns {SessionSaved | SessionRefused}
|
|
607
727
|
*/
|
|
608
728
|
export function saveSession(root, outcome, meta = {}) {
|
|
@@ -641,9 +761,23 @@ export function saveSession(root, outcome, meta = {}) {
|
|
|
641
761
|
// and it is the difference between a listable record and a resumable one.
|
|
642
762
|
resumable: clean.messages.length > 0,
|
|
643
763
|
truncated: clean.truncated,
|
|
764
|
+
/**
|
|
765
|
+
* ⚠️ A CLIPPED HEAD MEANS THE PROMPT CACHE CANNOT HIT ON RESUME — see
|
|
766
|
+
* MAX_HEAD_CHARS. Recorded so `resumeMessages` states it instead of leaving
|
|
767
|
+
* someone to discover it on the invoice.
|
|
768
|
+
*/
|
|
769
|
+
headTruncated: clean.headTruncated === true,
|
|
644
770
|
droppedGroups: clean.droppedGroups,
|
|
645
771
|
droppedIncomplete: clean.droppedIncomplete,
|
|
646
772
|
redactions: clean.redactions,
|
|
773
|
+
/**
|
|
774
|
+
* ⚠️ FALSE MEANS "THIS PROCESS NEVER GOT TO THE END", NOT "IT FAILED". A run
|
|
775
|
+
* that stopped at the round cap closed cleanly; a run whose laptop shut is
|
|
776
|
+
* the one this flag is for. Absent (an older record) is read as TRUE, so
|
|
777
|
+
* upgrading never manufactures a crash offer for a run that finished fine.
|
|
778
|
+
*/
|
|
779
|
+
closedCleanly: meta.live !== true,
|
|
780
|
+
pid: typeof meta.pid === 'number' ? meta.pid : process.pid,
|
|
647
781
|
messages: clean.messages,
|
|
648
782
|
};
|
|
649
783
|
|
|
@@ -792,6 +926,17 @@ export function resumeMessages(root, id) {
|
|
|
792
926
|
if (s.files.length > 0) {
|
|
793
927
|
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
928
|
}
|
|
929
|
+
if (s.closedCleanly === false) {
|
|
930
|
+
/**
|
|
931
|
+
* ⚠️ SAY THAT IT DIED, because the model's own transcript gives it no way to
|
|
932
|
+
* tell. A record closed at the round cap and a record whose process was
|
|
933
|
+
* killed look identical from the inside — and the difference matters: the
|
|
934
|
+
* killed one may have started a tool call whose EFFECT landed while its
|
|
935
|
+
* result never did, so "check the disk before redoing anything" is advice
|
|
936
|
+
* only this branch can honestly give.
|
|
937
|
+
*/
|
|
938
|
+
bits.push('⚠️ That run was killed mid-round (the process never reached the end), so its final round may be missing from this history even though its work may already be on disk.');
|
|
939
|
+
}
|
|
795
940
|
if (s.droppedIncomplete > 0) {
|
|
796
941
|
bits.push('Its last round was incomplete and has been discarded, so the final tool call it started never finished.');
|
|
797
942
|
}
|
|
@@ -817,6 +962,16 @@ export function resumeMessages(root, id) {
|
|
|
817
962
|
messages: [...s.messages, { role: 'user', content: note }],
|
|
818
963
|
note,
|
|
819
964
|
rootChanged,
|
|
965
|
+
/**
|
|
966
|
+
* ⭐ WHETHER THE DISCOUNT SURVIVED. The first two messages ARE the cacheable
|
|
967
|
+
* prefix, so a record whose head was clipped resumes into a prompt that
|
|
968
|
+
* differs from the original at message zero and cannot hit the cache at all
|
|
969
|
+
* — see MAX_HEAD_CHARS for the 60.8% this was measured at before the head
|
|
970
|
+
* got its own ceiling. Reported so the CLI can say so out loud; silence here
|
|
971
|
+
* is how a 4.3x price difference goes unnoticed.
|
|
972
|
+
*/
|
|
973
|
+
headTruncated: s.headTruncated === true,
|
|
974
|
+
crashed: s.closedCleanly === false,
|
|
820
975
|
replayed: false,
|
|
821
976
|
};
|
|
822
977
|
}
|
|
@@ -863,12 +1018,147 @@ export function listSessions(root, { limit = 10 } = {}) {
|
|
|
863
1018
|
commands: s.commands.length,
|
|
864
1019
|
stoppedBecause: s.stoppedBecause,
|
|
865
1020
|
resumable: s.resumable === true,
|
|
1021
|
+
// ⚠️ ABSENT READS AS CLEAN. Records written before this field existed are
|
|
1022
|
+
// finished runs, and inventing a crash for them would greet every upgrade
|
|
1023
|
+
// with an offer to recover something that never broke.
|
|
1024
|
+
closedCleanly: s.closedCleanly !== false,
|
|
1025
|
+
pid: typeof s.pid === 'number' ? s.pid : null,
|
|
866
1026
|
summary: summarizeSession(s),
|
|
867
1027
|
});
|
|
868
1028
|
}
|
|
869
1029
|
return { ok: true, sessions, unreadable };
|
|
870
1030
|
}
|
|
871
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* ── ⭐⭐⭐ "MY LAPTOP CRASHED. WHERE DID MY CONVERSATION GO?" ────────────────
|
|
1034
|
+
*
|
|
1035
|
+
* The one question this module existed to answer and could not. `listSessions`
|
|
1036
|
+
* shows you everything and makes you pick; `--continue` takes the newest and
|
|
1037
|
+
* asks nothing. Neither of them tells you, unprompted, that the run you were in
|
|
1038
|
+
* the middle of never finished — and a recovery you have to already know about
|
|
1039
|
+
* is a recovery for the person who does not need it.
|
|
1040
|
+
*
|
|
1041
|
+
* ⚠️ THREE THINGS MUST ALL BE TRUE, and each one has a case behind it:
|
|
1042
|
+
*
|
|
1043
|
+
* · `closedCleanly === false` — it really did die mid-run. A run that stopped
|
|
1044
|
+
* at the round cap is FINISHED; offering to rescue it would train people to
|
|
1045
|
+
* dismiss this prompt, which is how the real one gets dismissed too.
|
|
1046
|
+
* · `resumable` — there is a conversation to restore. A record with no
|
|
1047
|
+
* messages can be listed and cannot be continued, and offering it would be
|
|
1048
|
+
* a promise the next step breaks.
|
|
1049
|
+
* · the pid does not answer — nobody is running it RIGHT NOW. Seven terminals
|
|
1050
|
+
* in one workspace is the documented normal case for this tool, and every
|
|
1051
|
+
* one of them holds an open live record. Without this check, opening a
|
|
1052
|
+
* second terminal would offer to "recover" the session the first one is
|
|
1053
|
+
* still working in.
|
|
1054
|
+
*
|
|
1055
|
+
* ⚠️ AND IT NEVER THROWS. It runs on the startup path of every ordinary run, so
|
|
1056
|
+
* a corrupt file or an unreadable directory must cost the offer and nothing
|
|
1057
|
+
* else.
|
|
1058
|
+
*
|
|
1059
|
+
* @param {string} root
|
|
1060
|
+
* @param {{ limit?: number, selfPid?: number, isAlive?: (pid: number) => boolean }} [opts]
|
|
1061
|
+
* @returns {{ ok: true, crashed: null | { id: string, savedAt: string, task: string, roundsUsed: number, files: number, pid: number | null, summary: string } } | SessionRefused}
|
|
1062
|
+
*/
|
|
1063
|
+
export function findCrashedSession(root, { limit = 5, selfPid = process.pid, isAlive = pidIsAlive } = {}) {
|
|
1064
|
+
let listed;
|
|
1065
|
+
try { listed = listSessions(root, { limit }); } catch (e) { return { ok: false, error: err(e) }; }
|
|
1066
|
+
if (!listed.ok) return listed;
|
|
1067
|
+
|
|
1068
|
+
for (const s of listed.sessions) {
|
|
1069
|
+
if (s.closedCleanly) continue;
|
|
1070
|
+
if (!s.resumable) continue;
|
|
1071
|
+
/**
|
|
1072
|
+
* ⚠️ OUR OWN PID IS "ALIVE" TRIVIALLY, so it is excluded explicitly rather
|
|
1073
|
+
* than relied on. A process that re-enters this function after writing its
|
|
1074
|
+
* own live record would otherwise skip it for the right reason by accident,
|
|
1075
|
+
* and an accident is not a guard.
|
|
1076
|
+
*/
|
|
1077
|
+
if (s.pid !== null && (s.pid === selfPid || isAlive(s.pid))) continue;
|
|
1078
|
+
return {
|
|
1079
|
+
ok: true,
|
|
1080
|
+
crashed: {
|
|
1081
|
+
id: s.id, savedAt: s.savedAt, task: s.task, roundsUsed: s.roundsUsed,
|
|
1082
|
+
files: s.files, pid: s.pid, summary: s.summary,
|
|
1083
|
+
},
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
return { ok: true, crashed: null };
|
|
1087
|
+
}
|
|
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
|
+
|
|
1125
|
+
/**
|
|
1126
|
+
* Does this pid still answer?
|
|
1127
|
+
*
|
|
1128
|
+
* ⚠️ `EPERM` MEANS ALIVE, NOT DEAD, and reading it the other way is the
|
|
1129
|
+
* dangerous direction: a pid owned by another user exists, and treating it as
|
|
1130
|
+
* gone is how we offer to resume a conversation somebody else is having. Only
|
|
1131
|
+
* `ESRCH` — no such process — is an answer of "no".
|
|
1132
|
+
*/
|
|
1133
|
+
export function pidIsAlive(pid) {
|
|
1134
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1135
|
+
try {
|
|
1136
|
+
process.kill(pid, 0);
|
|
1137
|
+
return true;
|
|
1138
|
+
} catch (e) {
|
|
1139
|
+
return e?.code !== 'ESRCH';
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* ⭐ THE EXACT SENTENCE A PERSON SEES AFTER THEIR LAPTOP DIED, exported so the
|
|
1145
|
+
* test asserts what they read rather than a paraphrase of it — the rule
|
|
1146
|
+
* `interrupt.mjs` set for FIRST_PRESS_NOTICE, for the same reason: the promise
|
|
1147
|
+
* this line makes has to stay wired to the code that keeps it.
|
|
1148
|
+
*
|
|
1149
|
+
* ⚠️ IT NAMES THE COMMAND. "A previous run did not finish" with no next step is
|
|
1150
|
+
* a notification, not a recovery, and the person reading it has just lost work.
|
|
1151
|
+
*/
|
|
1152
|
+
export function crashOfferLines(crashed, { command = 'acuvo --continue' } = {}) {
|
|
1153
|
+
const when = String(crashed?.savedAt ?? '').slice(0, 16).replace('T', ' ');
|
|
1154
|
+
const task = String(crashed?.task ?? '').replace(/\s+/g, ' ').trim();
|
|
1155
|
+
return [
|
|
1156
|
+
` ⚠ a run in this workspace never finished — it stopped mid-round at ${when} (UTC).`,
|
|
1157
|
+
` ${crashed?.roundsUsed ?? 0} round${crashed?.roundsUsed === 1 ? '' : 's'} and ${crashed?.files ?? 0} file change${crashed?.files === 1 ? '' : 's'} were recorded: ${task.length > 64 ? `${task.slice(0, 63)}…` : task}`,
|
|
1158
|
+
` Its conversation was saved. Carry on from it with: ${command}`,
|
|
1159
|
+
];
|
|
1160
|
+
}
|
|
1161
|
+
|
|
872
1162
|
/**
|
|
873
1163
|
* One line, and it has to earn its width: the id (which is what you type to
|
|
874
1164
|
* resume), when, how far it got, what it produced, and why it stopped. The task
|
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
|
*
|