@pellux/goodvibes-tui 1.20.0 → 1.20.1
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/CHANGELOG.md +7 -0
- package/README.md +7 -7
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/management.ts +1 -1
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +20 -17
- package/src/core/stream-event-wiring.ts +174 -14
- package/src/core/system-message-router.ts +22 -0
- package/src/main.ts +20 -19
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/shell-surface.ts +36 -0
- package/src/renderer/ui-factory.ts +25 -78
- package/src/runtime/recovery-decisions.ts +126 -0
- package/src/runtime/recovery-prompt.ts +56 -6
- package/src/version.ts +1 -1
|
@@ -3,6 +3,7 @@ import { LAYOUT } from './layout.ts';
|
|
|
3
3
|
import { VERSION } from '../version.ts';
|
|
4
4
|
import { fitDisplay, getDisplayWidth, truncateDisplay, wrapText, interpolateColor, joinPrioritizedSegments, type PrioritizedSegment } from '../utils/terminal-width.ts';
|
|
5
5
|
import type { GitHeaderInfo } from './git-status.ts';
|
|
6
|
+
import { renderHeaderLine } from './header-line.ts';
|
|
6
7
|
import { renderConversationFragment, renderConversationStatusLine, type ConversationStatusSegment } from './conversation-surface.ts';
|
|
7
8
|
import { GLYPHS } from './ui-primitives.ts';
|
|
8
9
|
import { activeUiTones } from './theme.ts';
|
|
@@ -42,22 +43,6 @@ export interface ThinkingStallInfo {
|
|
|
42
43
|
readonly reconnect?: { readonly attempt: number; readonly maxAttempts: number };
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/** Build the git segment string and its display width. Single source of truth for header layout. */
|
|
46
|
-
function buildGitSegment(gitInfo: GitHeaderInfo): { text: string; width: number } {
|
|
47
|
-
const branch = ` git:${gitInfo.branch}`;
|
|
48
|
-
if (gitInfo.dirty) {
|
|
49
|
-
const text = `${branch} * `;
|
|
50
|
-
return { text, width: getDisplayWidth(text) };
|
|
51
|
-
}
|
|
52
|
-
if (gitInfo.ahead > 0 || gitInfo.behind > 0) {
|
|
53
|
-
const arrows = (gitInfo.ahead > 0 ? ` +${gitInfo.ahead}` : '') + (gitInfo.behind > 0 ? ` -${gitInfo.behind}` : '');
|
|
54
|
-
const text = `${branch}${arrows} `;
|
|
55
|
-
return { text, width: getDisplayWidth(text) };
|
|
56
|
-
}
|
|
57
|
-
const text = `${branch} `;
|
|
58
|
-
return { text, width: getDisplayWidth(text) };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
46
|
/** Format a number: up to 999, then 1.0k, 1.0M, 1.0B, 1.0T */
|
|
62
47
|
function fmtNum(n: number): string {
|
|
63
48
|
return abbreviateCount(n, { bSuffix: true });
|
|
@@ -75,68 +60,19 @@ function fmtCost(usd: number): string {
|
|
|
75
60
|
* UIFactory - Generates standard UI fragments without needing Ink/React overhead.
|
|
76
61
|
*/
|
|
77
62
|
export class UIFactory {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const brand = ` GoodVibes `;
|
|
92
|
-
const ver = `v${version} `;
|
|
93
|
-
const stats = ` ${model} `;
|
|
94
|
-
const prov = `(${provider}) `;
|
|
95
|
-
const line = createEmptyLine(width);
|
|
96
|
-
let curX = 0;
|
|
97
|
-
for (const char of brand) { line[curX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
98
|
-
for (const char of ver) { line[curX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
99
|
-
// Optional conversation title — shown after brand/ver, truncated to fit
|
|
100
|
-
if (title) {
|
|
101
|
-
const titleStr = `│ ${title} `;
|
|
102
|
-
// Reserve space for git info (if present) + model/provider on the right
|
|
103
|
-
const gitReserved = gitInfo ? buildGitSegment(gitInfo).width : 0;
|
|
104
|
-
const rightReserved = getDisplayWidth(stats + prov) + gitReserved;
|
|
105
|
-
const maxTitleW = width - curX - rightReserved - 1;
|
|
106
|
-
let displayTitle: string;
|
|
107
|
-
if (getDisplayWidth(titleStr) <= maxTitleW) {
|
|
108
|
-
displayTitle = titleStr;
|
|
109
|
-
} else {
|
|
110
|
-
let truncated = '';
|
|
111
|
-
let w = 0;
|
|
112
|
-
for (const ch of titleStr) {
|
|
113
|
-
const cw = getDisplayWidth(ch);
|
|
114
|
-
if (w + cw > maxTitleW - 3) { truncated += '...'; break; }
|
|
115
|
-
truncated += ch;
|
|
116
|
-
w += cw;
|
|
117
|
-
}
|
|
118
|
-
displayTitle = truncated;
|
|
119
|
-
}
|
|
120
|
-
for (const char of displayTitle) { if (curX < width) line[curX++] = { char, fg: TITLE_COLOR, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
121
|
-
}
|
|
122
|
-
// Build git info segment
|
|
123
|
-
let gitStr = '';
|
|
124
|
-
let gitFg: string = t.chrome.faint;
|
|
125
|
-
if (gitInfo) {
|
|
126
|
-
gitStr = buildGitSegment(gitInfo).text;
|
|
127
|
-
if (gitInfo.dirty || gitInfo.ahead > 0 || gitInfo.behind > 0) {
|
|
128
|
-
gitFg = t.chrome.warn; // yellow when dirty or out-of-sync
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const rightSideText = stats + prov;
|
|
132
|
-
const rightSideW = getDisplayWidth(rightSideText) + getDisplayWidth(gitStr);
|
|
133
|
-
let rightX = width - rightSideW;
|
|
134
|
-
for (const char of gitStr) { if (rightX >= 0 && rightX < width) line[rightX++] = { char, fg: gitFg, bg: '', bold: false, dim: !gitInfo?.dirty && !(gitInfo?.ahead || gitInfo?.behind), underline: false, italic: false, strikethrough: false }; }
|
|
135
|
-
for (const char of stats) { if (rightX < width) line[rightX++] = { char, fg: CYAN, bg: '', bold: true, dim: false, underline: false, italic: false, strikethrough: false }; }
|
|
136
|
-
for (const char of prov) { if (rightX < width) line[rightX++] = { char, fg: GREY, bg: '', bold: false, dim: true, underline: false, italic: false, strikethrough: false }; }
|
|
137
|
-
lines.push(line);
|
|
138
|
-
lines.push(this.stringToLine('━'.repeat(width), width, { fg: t.chrome.faint }));
|
|
139
|
-
return lines;
|
|
63
|
+
/**
|
|
64
|
+
* The header row. Delegates to renderHeaderLine (header-line.ts) — extracted
|
|
65
|
+
* when the header gained the failover divergence marker and this file was
|
|
66
|
+
* already on the 800-line source gate.
|
|
67
|
+
*
|
|
68
|
+
* `model`/`provider` are the SERVING backend and `note` the divergence
|
|
69
|
+
* marker, both resolved by core/active-model-identity.ts so the header and
|
|
70
|
+
* the footer answer "who is serving?" from one place. `version` defaults to
|
|
71
|
+
* the live build VERSION; tests pass a pinned fixture so golden snapshots
|
|
72
|
+
* don't break on every release bump.
|
|
73
|
+
*/
|
|
74
|
+
public static createHeader(width: number, model: string, provider: string, title?: string, gitInfo?: GitHeaderInfo, version: string = VERSION, note: string = ''): Line[] {
|
|
75
|
+
return renderHeaderLine(width, model, provider, title, gitInfo, version, note);
|
|
140
76
|
}
|
|
141
77
|
|
|
142
78
|
/**
|
|
@@ -221,6 +157,11 @@ export class UIFactory {
|
|
|
221
157
|
// "sleep disabled" chip in the posture block (the danger-mode idiom: a
|
|
222
158
|
// persistent, colored surface indicator, the safety mechanism itself).
|
|
223
159
|
powerKeepAwake?: boolean,
|
|
160
|
+
// Divergence marker for the model segment (core/active-model-identity.ts):
|
|
161
|
+
// set only while the serving backend is not the user's configured
|
|
162
|
+
// selection, e.g. "failover from abacusai:route-llm". Empty/undefined
|
|
163
|
+
// renders the context-info line exactly as it did before the marker existed.
|
|
164
|
+
modelNote?: string,
|
|
224
165
|
): Line[] {
|
|
225
166
|
const lines: Line[] = [];
|
|
226
167
|
const promptLines = prompt.split('\n');
|
|
@@ -499,6 +440,12 @@ export class UIFactory {
|
|
|
499
440
|
if (model) {
|
|
500
441
|
ctxParts.push({ text: model + (provider ? ` (${provider})` : ''), priority: 0 });
|
|
501
442
|
}
|
|
443
|
+
// Divergence marker — its own segment so a narrow line drops it WHOLE
|
|
444
|
+
// (joinPrioritizedSegments breaks priority ties toward the later
|
|
445
|
+
// segment) rather than truncating it into a half-word. Priority 0: it
|
|
446
|
+
// outranks every decorative segment, and what survives without it is
|
|
447
|
+
// still the SERVING backend, never a claim about the configured one.
|
|
448
|
+
if (modelNote) ctxParts.push({ text: modelNote, priority: 0 });
|
|
502
449
|
// Cross-surface spine posture — plain words, no blame. Adopted mode only.
|
|
503
450
|
if (sessionSpineStatus) ctxParts.push({ text: `spine:${sessionSpineStatus}`, priority: 1 });
|
|
504
451
|
// Web surface reachability — plain, persistent; dropped first under width pressure.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recovery-decisions.ts — a durable record of the recovery snapshots the user
|
|
3
|
+
* has already said "Remove" to.
|
|
4
|
+
*
|
|
5
|
+
* The defect this exists for: answering Remove deletes the snapshot file, and
|
|
6
|
+
* that was the whole memory of the decision. Anything that puts the file back
|
|
7
|
+
* — a session still running on an older build rewriting its snapshot every
|
|
8
|
+
* 60s, a restored backup, a synced directory — produced the same question
|
|
9
|
+
* again on the next launch, and again after the next removal, with no way for
|
|
10
|
+
* the user to make it stop. A decision the product forgets the instant it acts
|
|
11
|
+
* on it is not a decision the user gets to make.
|
|
12
|
+
*
|
|
13
|
+
* So Remove now writes a record that outlives the process, and a recorded
|
|
14
|
+
* snapshot found on disk again is deleted without asking. "Keep" is
|
|
15
|
+
* deliberately NOT recorded here: Keep means "leave it, ask me next launch",
|
|
16
|
+
* and recovery-prompt.ts's in-process set already holds it for the rest of the
|
|
17
|
+
* run.
|
|
18
|
+
*
|
|
19
|
+
* WHERE IT LIVES, and why not under `surface.stateDir`:
|
|
20
|
+
* `<homeDirectory>/.goodvibes/<surfaceRoot>/recovery-decisions.json` — home-
|
|
21
|
+
* anchored, matching session-liveness-marker.ts, and deliberately NOT the
|
|
22
|
+
* project-anchored `surface.stateDir`. The snapshots this defends against
|
|
23
|
+
* include the ones in the SDK's legacy shared recovery directory, which is
|
|
24
|
+
* itself home-anchored and shared by every project that ever used this
|
|
25
|
+
* surfaceRoot; the current build dual-reads it, so ONE such snapshot is
|
|
26
|
+
* offered at the launch of EVERY project. A project-scoped record could only
|
|
27
|
+
* ever silence one project at a time, which is the same nag wearing a
|
|
28
|
+
* different hat. Records are matched on session id alone; ids are unique, so
|
|
29
|
+
* a record made in one project cannot suppress a different snapshot in
|
|
30
|
+
* another. Each record still carries the workspace it was made in, so the file
|
|
31
|
+
* says plainly where the decision came from.
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { dirname, join } from 'node:path';
|
|
35
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
36
|
+
|
|
37
|
+
/** One "the user removed this snapshot" decision. */
|
|
38
|
+
export interface RecoveryRemovalRecord {
|
|
39
|
+
readonly sessionId: string;
|
|
40
|
+
/** The project the decision was made in. Provenance — matching is on sessionId alone. */
|
|
41
|
+
readonly workspace: string;
|
|
42
|
+
readonly removedAt: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Bounds on the ledger. It is append-mostly and never read by anything but
|
|
47
|
+
* the boot offer, so it stays small by construction; these are the guards
|
|
48
|
+
* against a pathological case, not a routine cleanup.
|
|
49
|
+
*
|
|
50
|
+
* Note what is deliberately NOT the pruning rule: "drop records whose snapshot
|
|
51
|
+
* file no longer exists". Every record in here describes a file that was just
|
|
52
|
+
* deleted, so that rule would empty the ledger immediately and reinstate the
|
|
53
|
+
* exact defect it was written to fix — the point is precisely to still
|
|
54
|
+
* remember once the file comes back. Age and count are the honest bounds.
|
|
55
|
+
*/
|
|
56
|
+
const MAX_RECORDS = 200;
|
|
57
|
+
const RECORD_TTL_MS = 90 * 24 * 60 * 60 * 1000;
|
|
58
|
+
|
|
59
|
+
/** Absolute path to this surface's removal ledger. */
|
|
60
|
+
export function recoveryDecisionsPathFor(surface: SessionSurface): string {
|
|
61
|
+
return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, 'recovery-decisions.json');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isRecord(value: unknown): value is RecoveryRemovalRecord {
|
|
65
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
66
|
+
const candidate = value as Partial<RecoveryRemovalRecord>;
|
|
67
|
+
return typeof candidate.sessionId === 'string'
|
|
68
|
+
&& candidate.sessionId.length > 0
|
|
69
|
+
&& typeof candidate.workspace === 'string'
|
|
70
|
+
&& typeof candidate.removedAt === 'number'
|
|
71
|
+
&& Number.isFinite(candidate.removedAt);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every removal decision still in force, oldest first. A missing, unreadable
|
|
76
|
+
* or malformed ledger reads as "no decisions recorded" rather than throwing:
|
|
77
|
+
* a corrupt file must not be able to take a boot down, and the worst outcome
|
|
78
|
+
* of losing it is that the user is asked once more.
|
|
79
|
+
*/
|
|
80
|
+
export function readRecoveryRemovals(surface: SessionSurface, nowMs: number = Date.now()): RecoveryRemovalRecord[] {
|
|
81
|
+
try {
|
|
82
|
+
const path = recoveryDecisionsPathFor(surface);
|
|
83
|
+
if (!existsSync(path)) return [];
|
|
84
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
|
|
85
|
+
if (!Array.isArray(parsed)) return [];
|
|
86
|
+
return parsed.filter(isRecord).filter((record) => nowMs - record.removedAt < RECORD_TTL_MS);
|
|
87
|
+
} catch {
|
|
88
|
+
return [];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Has the user already chosen Remove for this snapshot's session, in this or
|
|
94
|
+
* any earlier run?
|
|
95
|
+
*/
|
|
96
|
+
export function isRecoveryRemovalRecorded(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): boolean {
|
|
97
|
+
if (!sessionId.trim()) return false;
|
|
98
|
+
return readRecoveryRemovals(surface, nowMs).some((record) => record.sessionId === sessionId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Record that the user chose Remove for `sessionId`. Idempotent — a repeat
|
|
103
|
+
* removal of a snapshot that came back refreshes the existing record's
|
|
104
|
+
* timestamp rather than adding a second one, so the ledger cannot grow by one
|
|
105
|
+
* entry per reappearance.
|
|
106
|
+
*
|
|
107
|
+
* Best-effort: a ledger that cannot be written must not break the removal the
|
|
108
|
+
* user actually asked for. The cost of a failed write is one more question on
|
|
109
|
+
* a future launch, which is exactly the behaviour that shipped before this
|
|
110
|
+
* file existed.
|
|
111
|
+
*/
|
|
112
|
+
export function recordRecoveryRemoval(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): void {
|
|
113
|
+
if (!sessionId.trim()) return;
|
|
114
|
+
try {
|
|
115
|
+
const kept = readRecoveryRemovals(surface, nowMs).filter((record) => record.sessionId !== sessionId);
|
|
116
|
+
kept.push({ sessionId, workspace: surface.workingDirectory, removedAt: nowMs });
|
|
117
|
+
// Oldest decisions fall off first; the newest are the ones a reappearing
|
|
118
|
+
// snapshot is most likely to match.
|
|
119
|
+
const bounded = kept.slice(-MAX_RECORDS);
|
|
120
|
+
const path = recoveryDecisionsPathFor(surface);
|
|
121
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
122
|
+
writeFileSync(path, JSON.stringify(bounded, null, 2) + '\n', { mode: 0o600 });
|
|
123
|
+
} catch {
|
|
124
|
+
// Best-effort by construction — see the doc comment above.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -39,10 +39,18 @@
|
|
|
39
39
|
* without reading keeps the snapshot.
|
|
40
40
|
* - Keep (or a dismissal) stays quiet for the rest of the run. The snapshot
|
|
41
41
|
* is offered again on the next launch, not again in this one.
|
|
42
|
+
* - Remove is permanent, not just for this file. The decision is written to
|
|
43
|
+
* the durable ledger in recovery-decisions.ts, so a snapshot that comes
|
|
44
|
+
* back byte-for-byte — because a session still running on an older build
|
|
45
|
+
* is rewriting it every minute — is deleted on sight instead of asked
|
|
46
|
+
* about again. Deleting the file used to be the entire memory of the
|
|
47
|
+
* answer, which is how the same question survived being answered three
|
|
48
|
+
* times.
|
|
42
49
|
*/
|
|
43
50
|
import { statSync } from 'node:fs';
|
|
44
51
|
import { checkRecoveryFile, checkRecoveryForSession, consumeRecovery, removeRecoveryPoint } from '@/runtime/index.ts';
|
|
45
52
|
import type { RecoveryFileInfo, SessionSurface } from '@/runtime/index.ts';
|
|
53
|
+
import { isRecoveryRemovalRecorded, recordRecoveryRemoval } from './recovery-decisions.ts';
|
|
46
54
|
import { checkSessionLiveness } from './session-liveness-marker.ts';
|
|
47
55
|
import type { SelectionItem, SelectionResult } from '../input/selection-modal.ts';
|
|
48
56
|
|
|
@@ -213,11 +221,50 @@ function defaultSnapshotBytes(surface: SessionSurface, sessionId: string): numbe
|
|
|
213
221
|
*/
|
|
214
222
|
const answeredThisRun = new Set<string>();
|
|
215
223
|
|
|
216
|
-
/**
|
|
224
|
+
/**
|
|
225
|
+
* Test seam: a fresh process has no answered offers. Only clears the
|
|
226
|
+
* in-process Keep memory — removal decisions are durable on purpose and live
|
|
227
|
+
* in the surface's own directory, so a test gets a clean ledger by using a
|
|
228
|
+
* fresh temp surface, the same way a real user gets one by using a different
|
|
229
|
+
* machine.
|
|
230
|
+
*/
|
|
217
231
|
export function resetAnsweredRecoveryOffersForTest(): void {
|
|
218
232
|
answeredThisRun.clear();
|
|
219
233
|
}
|
|
220
234
|
|
|
235
|
+
/**
|
|
236
|
+
* How many times one offer pass will delete a recorded-removed snapshot and
|
|
237
|
+
* look again. A bound rather than a `while (true)`: the same session can have
|
|
238
|
+
* a snapshot in both the canonical and the legacy shared directory, so one
|
|
239
|
+
* removal can legitimately need a second pass, but nothing here should be able
|
|
240
|
+
* to spin if a delete ever fails to take.
|
|
241
|
+
*/
|
|
242
|
+
const MAX_RECORDED_REMOVAL_PASSES = 8;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The snapshot to actually ask about, after honouring decisions already made.
|
|
246
|
+
*
|
|
247
|
+
* A snapshot whose session is in the removal ledger is deleted here and NOT
|
|
248
|
+
* offered: the user answered that question already, and re-asking because the
|
|
249
|
+
* file came back is the defect. Deleting rather than merely skipping matters —
|
|
250
|
+
* skipping would leave the file to mask an older, genuinely-orphaned snapshot
|
|
251
|
+
* underneath it forever.
|
|
252
|
+
*/
|
|
253
|
+
function findOfferableSnapshot(deps: RecoveryPromptDeps): RecoveryFileInfo | null {
|
|
254
|
+
for (let pass = 0; pass < MAX_RECORDED_REMOVAL_PASSES; pass += 1) {
|
|
255
|
+
const info = deps.targetSessionId !== undefined
|
|
256
|
+
? checkRecoveryForSession(deps.surface, deps.targetSessionId)
|
|
257
|
+
: checkRecoveryFile({ surface: deps.surface });
|
|
258
|
+
if (!info) return null;
|
|
259
|
+
if (!isRecoveryRemovalRecorded(deps.surface, info.sessionId)) return info;
|
|
260
|
+
// Silently, because the user already said remove. A receipt here would be
|
|
261
|
+
// a notification about a decision they made and do not need re-confirmed.
|
|
262
|
+
const { removed } = removeRecoveryPoint(deps.surface, info.sessionId);
|
|
263
|
+
if (!removed) return null;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
221
268
|
/**
|
|
222
269
|
* Run the startup recovery offer. Resolves once the user has answered (or
|
|
223
270
|
* once it is established that there is nothing to ask about). Never throws:
|
|
@@ -225,9 +272,7 @@ export function resetAnsweredRecoveryOffersForTest(): void {
|
|
|
225
272
|
*/
|
|
226
273
|
export async function offerRecoverySnapshot(deps: RecoveryPromptDeps): Promise<RecoveryPromptOutcome> {
|
|
227
274
|
try {
|
|
228
|
-
const info = deps
|
|
229
|
-
? checkRecoveryForSession(deps.surface, deps.targetSessionId)
|
|
230
|
-
: checkRecoveryFile({ surface: deps.surface });
|
|
275
|
+
const info = findOfferableSnapshot(deps);
|
|
231
276
|
if (!info) return 'none';
|
|
232
277
|
// Already answered about this snapshot earlier in this same run (the
|
|
233
278
|
// targeted --continue offer and the general startup offer can both find
|
|
@@ -270,9 +315,14 @@ export async function offerRecoverySnapshot(deps: RecoveryPromptDeps): Promise<R
|
|
|
270
315
|
const retire = await ask(open, RECOVERY_RETIRE_TITLE, buildRecoveryRetireItems(facts));
|
|
271
316
|
if (retire === 'remove') {
|
|
272
317
|
const { removed } = removeRecoveryPoint(deps.surface, info.sessionId);
|
|
318
|
+
// Recorded whether or not a file was there to delete: the answer is
|
|
319
|
+
// about this snapshot, and a snapshot that vanished between the question
|
|
320
|
+
// and the answer can still be rewritten a minute later by whatever wrote
|
|
321
|
+
// it in the first place.
|
|
322
|
+
recordRecoveryRemoval(deps.surface, info.sessionId);
|
|
273
323
|
deps.receipt(removed
|
|
274
|
-
? `Recovery point removed (session ${info.sessionId}).`
|
|
275
|
-
: `No recovery point was found to remove (session ${info.sessionId}).`);
|
|
324
|
+
? `Recovery point removed (session ${info.sessionId}) — it will not be offered again, even if the file reappears.`
|
|
325
|
+
: `No recovery point was found to remove (session ${info.sessionId}) — if one reappears for that session it will be discarded, not offered.`);
|
|
276
326
|
deps.render();
|
|
277
327
|
return 'removed';
|
|
278
328
|
}
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '1.20.
|
|
9
|
+
let _version = '1.20.1';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
// Only trust a version read from OUR OWN package.json. A Bun single-file
|