@pellux/goodvibes-tui 1.20.0 → 1.21.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 (65) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +7 -7
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/bundle-command.ts +1 -1
  6. package/src/cli/doctor.ts +7 -1
  7. package/src/cli/management.ts +1 -1
  8. package/src/cli/package-verification.ts +92 -7
  9. package/src/cli/status.ts +16 -0
  10. package/src/config/read-versioned.ts +115 -2
  11. package/src/core/active-model-identity.ts +153 -0
  12. package/src/core/conversation-event-navigation.ts +42 -0
  13. package/src/core/conversation-search-expansion.ts +67 -0
  14. package/src/core/conversation-splash-state.ts +58 -0
  15. package/src/core/conversation-user-receipts.ts +42 -0
  16. package/src/core/conversation.ts +43 -40
  17. package/src/core/model-routing-chip.ts +38 -18
  18. package/src/core/rewind-turn-anchors.ts +155 -3
  19. package/src/core/stream-event-wiring.ts +298 -17
  20. package/src/core/system-message-router.ts +22 -0
  21. package/src/core/transcript-journal.ts +145 -7
  22. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  23. package/src/daemon/handlers/inbox/index.ts +14 -1
  24. package/src/daemon/handlers/sqlite-store.ts +160 -6
  25. package/src/input/command-registry.ts +8 -0
  26. package/src/input/commands/local-runtime.ts +6 -1
  27. package/src/input/commands/schedule-runtime.ts +15 -2
  28. package/src/input/commands/shell-core.ts +87 -38
  29. package/src/input/composer-capture.ts +47 -0
  30. package/src/input/handler-command-route.ts +4 -0
  31. package/src/input/handler-content-actions.ts +9 -0
  32. package/src/input/handler-feed-routes.ts +1 -0
  33. package/src/input/handler-modal-routes.ts +12 -1
  34. package/src/input/handler-picker-routes.ts +43 -5
  35. package/src/input/handler-shortcuts.ts +1 -1
  36. package/src/input/handler-ui-state.ts +8 -3
  37. package/src/input/model-picker-items.ts +17 -2
  38. package/src/input/model-picker.ts +19 -2
  39. package/src/input/search.ts +183 -89
  40. package/src/main.ts +31 -38
  41. package/src/{cli → providers}/provider-classification.ts +12 -0
  42. package/src/providers/reasoning-effort-surface.ts +399 -0
  43. package/src/renderer/compositor.ts +68 -18
  44. package/src/renderer/header-line.ts +146 -0
  45. package/src/renderer/markdown-inline.ts +143 -0
  46. package/src/renderer/markdown-table.ts +459 -0
  47. package/src/renderer/markdown.ts +6 -335
  48. package/src/renderer/model-workspace.ts +48 -5
  49. package/src/renderer/shell-surface.ts +36 -0
  50. package/src/renderer/ui-factory.ts +25 -78
  51. package/src/runtime/bootstrap-command-parts.ts +36 -4
  52. package/src/runtime/bootstrap-core.ts +1 -1
  53. package/src/runtime/durability-housekeeping.ts +183 -0
  54. package/src/runtime/durability-services.ts +68 -11
  55. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  56. package/src/runtime/provider-fallback.ts +16 -2
  57. package/src/runtime/recovery-decisions.ts +295 -0
  58. package/src/runtime/recovery-prompt.ts +66 -6
  59. package/src/runtime/services.ts +9 -4
  60. package/src/runtime/session-liveness-marker.ts +142 -5
  61. package/src/runtime/workstream-draft-store.ts +198 -25
  62. package/src/runtime/workstream-services.ts +17 -2
  63. package/src/shell/ui-openers.ts +1 -1
  64. package/src/version.ts +1 -1
  65. package/src/work-plans/work-plan-store.ts +334 -16
@@ -0,0 +1,295 @@
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, renameSync, rmSync, writeFileSync } from 'node:fs';
34
+ import { dirname, join } from 'node:path';
35
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
36
+ import type { SessionSurface } from '@/runtime/index.ts';
37
+
38
+ /** One "the user removed this snapshot" decision. */
39
+ export interface RecoveryRemovalRecord {
40
+ readonly sessionId: string;
41
+ /** The project the decision was made in. Provenance — matching is on sessionId alone. */
42
+ readonly workspace: string;
43
+ readonly removedAt: number;
44
+ }
45
+
46
+ /**
47
+ * Bounds on the ledger. It is append-mostly and never read by anything but
48
+ * the boot offer, so it stays small by construction; these are the guards
49
+ * against a pathological case, not a routine cleanup.
50
+ *
51
+ * Note what is deliberately NOT the pruning rule: "drop records whose snapshot
52
+ * file no longer exists". Every record in here describes a file that was just
53
+ * deleted, so that rule would empty the ledger immediately and reinstate the
54
+ * exact defect it was written to fix — the point is precisely to still
55
+ * remember once the file comes back. Age and count are the honest bounds.
56
+ *
57
+ * Both bounds are enforced wherever records actually leave the disk —
58
+ * pruneRecoveryDecisions (once per boot) and recordRecoveryRemoval (every
59
+ * write) — and both DISCLOSE what they discarded. A deletion nobody reports is
60
+ * indistinguishable from data loss, so the counts go in the log with the ledger
61
+ * path, the TTL and the cap alongside them.
62
+ */
63
+ const MAX_RECORDS = 200;
64
+ const RECORD_TTL_MS = 90 * 24 * 60 * 60 * 1000;
65
+
66
+ /** Absolute path to this surface's removal ledger. */
67
+ export function recoveryDecisionsPathFor(surface: SessionSurface): string {
68
+ return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, 'recovery-decisions.json');
69
+ }
70
+
71
+ function isRecord(value: unknown): value is RecoveryRemovalRecord {
72
+ if (typeof value !== 'object' || value === null) return false;
73
+ const candidate = value as Partial<RecoveryRemovalRecord>;
74
+ return typeof candidate.sessionId === 'string'
75
+ && candidate.sessionId.length > 0
76
+ && typeof candidate.workspace === 'string'
77
+ && typeof candidate.removedAt === 'number'
78
+ && Number.isFinite(candidate.removedAt);
79
+ }
80
+
81
+ /** What one load of the ledger found, including everything it discarded. */
82
+ interface LedgerLoad {
83
+ /** Records still in force, oldest first. */
84
+ readonly kept: RecoveryRemovalRecord[];
85
+ /** Records dropped because they aged past the TTL. */
86
+ readonly expired: number;
87
+ /** Entries dropped because they were not shaped like a record at all. */
88
+ readonly malformed: number;
89
+ /** The ledger file was present but could not be read or parsed as an array. */
90
+ readonly unreadable: boolean;
91
+ /** The ledger file exists on disk. */
92
+ readonly present: boolean;
93
+ }
94
+
95
+ /**
96
+ * Load and filter the ledger. Validation is by CONTENT — the file is parsed and
97
+ * every entry is shape-checked — so a torn, truncated or zero-byte ledger left
98
+ * by a crash reads as "no decisions recorded" instead of being trusted because
99
+ * it exists. Never throws: a corrupt file must not be able to take a boot down,
100
+ * and the worst outcome of losing it is that the user is asked once more.
101
+ */
102
+ function loadLedger(surface: SessionSurface, nowMs: number): LedgerLoad {
103
+ const path = recoveryDecisionsPathFor(surface);
104
+ let present = false;
105
+ try {
106
+ present = existsSync(path);
107
+ if (!present) return { kept: [], expired: 0, malformed: 0, unreadable: false, present: false };
108
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
109
+ if (!Array.isArray(parsed)) return { kept: [], expired: 0, malformed: 0, unreadable: true, present };
110
+ const wellFormed = parsed.filter(isRecord);
111
+ const kept = wellFormed.filter((record) => nowMs - record.removedAt < RECORD_TTL_MS);
112
+ return {
113
+ kept,
114
+ expired: wellFormed.length - kept.length,
115
+ malformed: parsed.length - wellFormed.length,
116
+ unreadable: false,
117
+ present,
118
+ };
119
+ } catch {
120
+ return { kept: [], expired: 0, malformed: 0, unreadable: true, present };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Every removal decision still in force, oldest first. A missing, unreadable
126
+ * or malformed ledger reads as "no decisions recorded" rather than throwing:
127
+ * a corrupt file must not be able to take a boot down, and the worst outcome
128
+ * of losing it is that the user is asked once more.
129
+ *
130
+ * This is a filtered READ — it discards nothing from disk, so it stays silent.
131
+ * Disclosure belongs to the calls that actually delete: {@link pruneRecoveryDecisions}
132
+ * and {@link recordRecoveryRemoval}.
133
+ */
134
+ export function readRecoveryRemovals(surface: SessionSurface, nowMs: number = Date.now()): RecoveryRemovalRecord[] {
135
+ return loadLedger(surface, nowMs).kept;
136
+ }
137
+
138
+ /**
139
+ * Write `records` to the ledger through a temp file and an atomic rename.
140
+ *
141
+ * A plain `writeFileSync` onto the live path truncates first and fills after,
142
+ * so a crash — or a second TUI in another terminal writing the same home-
143
+ * anchored ledger at the same moment — could leave a half-written array that a
144
+ * later boot reads as an empty or corrupt ledger, silently forgetting every
145
+ * decision the user made. `rename` is atomic on the same filesystem: a reader
146
+ * sees either the whole old ledger or the whole new one. The temp name carries
147
+ * the pid so two writers cannot interleave into one temp file.
148
+ */
149
+ function writeLedgerAtomically(path: string, records: readonly RecoveryRemovalRecord[]): void {
150
+ const temp = `${path}.${process.pid}.tmp`;
151
+ mkdirSync(dirname(path), { recursive: true });
152
+ try {
153
+ writeFileSync(temp, JSON.stringify(records, null, 2) + '\n', { mode: 0o600 });
154
+ renameSync(temp, path);
155
+ } catch (error) {
156
+ try {
157
+ rmSync(temp, { force: true });
158
+ } catch {
159
+ // The temp file is inert; a failed cleanup is not worth a second error.
160
+ }
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ /** What a prune pass discarded, returned so callers (and tests) can assert it. */
166
+ export interface RecoveryDecisionsPruneOutcome {
167
+ /** Records dropped because they aged past the 90-day TTL. */
168
+ readonly expired: number;
169
+ /** Entries dropped because they were not shaped like a record. */
170
+ readonly malformed: number;
171
+ /** Records dropped because the ledger was over the 200-record cap. */
172
+ readonly overCap: number;
173
+ /** Records still in force after the prune. */
174
+ readonly kept: number;
175
+ /** The ledger file was present but unreadable or not an array. */
176
+ readonly unreadable: boolean;
177
+ }
178
+
179
+ /**
180
+ * Apply both bounds to the on-disk ledger and say what was discarded.
181
+ *
182
+ * `readRecoveryRemovals` filters in memory on every call, so the expired
183
+ * records were already inert — but they stayed on disk until the next time the
184
+ * user happened to answer "Remove", which on a machine where that never happens
185
+ * again is never. This makes the reap explicit and, crucially, VISIBLE: a
186
+ * silent deletion is indistinguishable from data loss, so every pass that drops
187
+ * anything logs the counts and the ledger path.
188
+ *
189
+ * Idempotent and safe to run from two processes at once: it is a pure function
190
+ * of the file's content plus `nowMs`, the rewrite is a temp-file-plus-rename,
191
+ * and a second run over an already-pruned ledger drops nothing and writes
192
+ * nothing. Best-effort — a ledger that cannot be pruned is left exactly as it
193
+ * was and never breaks a boot.
194
+ */
195
+ export function pruneRecoveryDecisions(surface: SessionSurface, nowMs: number = Date.now()): RecoveryDecisionsPruneOutcome {
196
+ const path = recoveryDecisionsPathFor(surface);
197
+ const load = loadLedger(surface, nowMs);
198
+ const overCap = Math.max(0, load.kept.length - MAX_RECORDS);
199
+ const bounded = load.kept.slice(-MAX_RECORDS);
200
+ const outcome: RecoveryDecisionsPruneOutcome = {
201
+ expired: load.expired,
202
+ malformed: load.malformed,
203
+ overCap,
204
+ kept: bounded.length,
205
+ unreadable: load.unreadable,
206
+ };
207
+
208
+ if (load.unreadable) {
209
+ // Do NOT rewrite: an unreadable ledger might be a transient read failure,
210
+ // and replacing it with `[]` would turn "could not read" into "forgot every
211
+ // decision the user made". Say so and leave it alone.
212
+ logger.warn('recovery decisions: ledger present but unreadable — left untouched, decisions may be re-asked', {
213
+ ledger: path,
214
+ });
215
+ return outcome;
216
+ }
217
+ const dropped = load.expired + load.malformed + overCap;
218
+ if (dropped === 0 || !load.present) return outcome;
219
+
220
+ try {
221
+ writeLedgerAtomically(path, bounded);
222
+ } catch {
223
+ // The in-memory filter already ignores these records; failing to persist
224
+ // the prune costs disk space, not correctness.
225
+ return outcome;
226
+ }
227
+ logger.info('recovery decisions: pruned the removal ledger', {
228
+ ledger: path,
229
+ expiredRecords: load.expired,
230
+ malformedEntries: load.malformed,
231
+ overCapRecords: overCap,
232
+ keptRecords: bounded.length,
233
+ ttlDays: Math.round(RECORD_TTL_MS / (24 * 60 * 60 * 1000)),
234
+ maxRecords: MAX_RECORDS,
235
+ });
236
+ return outcome;
237
+ }
238
+
239
+ /**
240
+ * Has the user already chosen Remove for this snapshot's session, in this or
241
+ * any earlier run?
242
+ */
243
+ export function isRecoveryRemovalRecorded(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): boolean {
244
+ if (!sessionId.trim()) return false;
245
+ return readRecoveryRemovals(surface, nowMs).some((record) => record.sessionId === sessionId);
246
+ }
247
+
248
+ /**
249
+ * Record that the user chose Remove for `sessionId`. Idempotent — a repeat
250
+ * removal of a snapshot that came back refreshes the existing record's
251
+ * timestamp rather than adding a second one, so the ledger cannot grow by one
252
+ * entry per reappearance.
253
+ *
254
+ * Best-effort: a ledger that cannot be written must not break the removal the
255
+ * user actually asked for. The cost of a failed write is one more question on
256
+ * a future launch, which is exactly the behaviour that shipped before this
257
+ * file existed.
258
+ */
259
+ export function recordRecoveryRemoval(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): void {
260
+ if (!sessionId.trim()) return;
261
+ const path = recoveryDecisionsPathFor(surface);
262
+ try {
263
+ const load = loadLedger(surface, nowMs);
264
+ if (load.unreadable) {
265
+ // Appending onto an unreadable ledger would rewrite it as a one-record
266
+ // array, silently discarding whatever the user decided before. Say so
267
+ // rather than quietly resetting their history.
268
+ logger.warn('recovery decisions: ledger unreadable — recording this removal replaces it', {
269
+ ledger: path,
270
+ sessionId,
271
+ });
272
+ }
273
+ const kept = load.kept.filter((record) => record.sessionId !== sessionId);
274
+ kept.push({ sessionId, workspace: surface.workingDirectory, removedAt: nowMs });
275
+ // Oldest decisions fall off first; the newest are the ones a reappearing
276
+ // snapshot is most likely to match.
277
+ const overCap = Math.max(0, kept.length - MAX_RECORDS);
278
+ const bounded = kept.slice(-MAX_RECORDS);
279
+ writeLedgerAtomically(path, bounded);
280
+ // Disclosure: this write is where records actually leave the disk, so it
281
+ // is where the counts get said out loud.
282
+ const dropped = load.expired + load.malformed + overCap;
283
+ if (dropped > 0) {
284
+ logger.info('recovery decisions: dropped records while recording a removal', {
285
+ ledger: path,
286
+ expiredRecords: load.expired,
287
+ malformedEntries: load.malformed,
288
+ overCapRecords: overCap,
289
+ keptRecords: bounded.length,
290
+ });
291
+ }
292
+ } catch {
293
+ // Best-effort by construction — see the doc comment above.
294
+ }
295
+ }
@@ -39,10 +39,21 @@
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.
49
+ * - The offer is also where that ledger gets swept. Every run prunes it to
50
+ * its age and count bounds and logs what it dropped, so the record of past
51
+ * decisions cannot quietly grow forever and nothing is deleted in silence.
42
52
  */
43
53
  import { statSync } from 'node:fs';
44
54
  import { checkRecoveryFile, checkRecoveryForSession, consumeRecovery, removeRecoveryPoint } from '@/runtime/index.ts';
45
55
  import type { RecoveryFileInfo, SessionSurface } from '@/runtime/index.ts';
56
+ import { isRecoveryRemovalRecorded, pruneRecoveryDecisions, recordRecoveryRemoval } from './recovery-decisions.ts';
46
57
  import { checkSessionLiveness } from './session-liveness-marker.ts';
47
58
  import type { SelectionItem, SelectionResult } from '../input/selection-modal.ts';
48
59
 
@@ -213,11 +224,50 @@ function defaultSnapshotBytes(surface: SessionSurface, sessionId: string): numbe
213
224
  */
214
225
  const answeredThisRun = new Set<string>();
215
226
 
216
- /** Test seam: a fresh process has no answered offers. */
227
+ /**
228
+ * Test seam: a fresh process has no answered offers. Only clears the
229
+ * in-process Keep memory — removal decisions are durable on purpose and live
230
+ * in the surface's own directory, so a test gets a clean ledger by using a
231
+ * fresh temp surface, the same way a real user gets one by using a different
232
+ * machine.
233
+ */
217
234
  export function resetAnsweredRecoveryOffersForTest(): void {
218
235
  answeredThisRun.clear();
219
236
  }
220
237
 
238
+ /**
239
+ * How many times one offer pass will delete a recorded-removed snapshot and
240
+ * look again. A bound rather than a `while (true)`: the same session can have
241
+ * a snapshot in both the canonical and the legacy shared directory, so one
242
+ * removal can legitimately need a second pass, but nothing here should be able
243
+ * to spin if a delete ever fails to take.
244
+ */
245
+ const MAX_RECORDED_REMOVAL_PASSES = 8;
246
+
247
+ /**
248
+ * The snapshot to actually ask about, after honouring decisions already made.
249
+ *
250
+ * A snapshot whose session is in the removal ledger is deleted here and NOT
251
+ * offered: the user answered that question already, and re-asking because the
252
+ * file came back is the defect. Deleting rather than merely skipping matters —
253
+ * skipping would leave the file to mask an older, genuinely-orphaned snapshot
254
+ * underneath it forever.
255
+ */
256
+ function findOfferableSnapshot(deps: RecoveryPromptDeps): RecoveryFileInfo | null {
257
+ for (let pass = 0; pass < MAX_RECORDED_REMOVAL_PASSES; pass += 1) {
258
+ const info = deps.targetSessionId !== undefined
259
+ ? checkRecoveryForSession(deps.surface, deps.targetSessionId)
260
+ : checkRecoveryFile({ surface: deps.surface });
261
+ if (!info) return null;
262
+ if (!isRecoveryRemovalRecorded(deps.surface, info.sessionId)) return info;
263
+ // Silently, because the user already said remove. A receipt here would be
264
+ // a notification about a decision they made and do not need re-confirmed.
265
+ const { removed } = removeRecoveryPoint(deps.surface, info.sessionId);
266
+ if (!removed) return null;
267
+ }
268
+ return null;
269
+ }
270
+
221
271
  /**
222
272
  * Run the startup recovery offer. Resolves once the user has answered (or
223
273
  * once it is established that there is nothing to ask about). Never throws:
@@ -225,9 +275,14 @@ export function resetAnsweredRecoveryOffersForTest(): void {
225
275
  */
226
276
  export async function offerRecoverySnapshot(deps: RecoveryPromptDeps): Promise<RecoveryPromptOutcome> {
227
277
  try {
228
- const info = deps.targetSessionId !== undefined
229
- ? checkRecoveryForSession(deps.surface, deps.targetSessionId)
230
- : checkRecoveryFile({ surface: deps.surface });
278
+ // Housekeeping first: recovery time is when the removal ledger gets its
279
+ // bounds actually applied to disk and says what it discarded. Without this
280
+ // the expired records were filtered on read but never removed, so on a
281
+ // machine where the user never answers "Remove" again the file only ever
282
+ // grew. Idempotent, so the second call in a run (targeted --continue offer
283
+ // plus the general startup offer) drops nothing and writes nothing.
284
+ pruneRecoveryDecisions(deps.surface, deps.now?.() ?? Date.now());
285
+ const info = findOfferableSnapshot(deps);
231
286
  if (!info) return 'none';
232
287
  // Already answered about this snapshot earlier in this same run (the
233
288
  // targeted --continue offer and the general startup offer can both find
@@ -270,9 +325,14 @@ export async function offerRecoverySnapshot(deps: RecoveryPromptDeps): Promise<R
270
325
  const retire = await ask(open, RECOVERY_RETIRE_TITLE, buildRecoveryRetireItems(facts));
271
326
  if (retire === 'remove') {
272
327
  const { removed } = removeRecoveryPoint(deps.surface, info.sessionId);
328
+ // Recorded whether or not a file was there to delete: the answer is
329
+ // about this snapshot, and a snapshot that vanished between the question
330
+ // and the answer can still be rewritten a minute later by whatever wrote
331
+ // it in the first place.
332
+ recordRecoveryRemoval(deps.surface, info.sessionId);
273
333
  deps.receipt(removed
274
- ? `Recovery point removed (session ${info.sessionId}).`
275
- : `No recovery point was found to remove (session ${info.sessionId}).`);
334
+ ? `Recovery point removed (session ${info.sessionId}) — it will not be offered again, even if the file reappears.`
335
+ : `No recovery point was found to remove (session ${info.sessionId}) — if one reappears for that session it will be discarded, not offered.`);
276
336
  deps.render();
277
337
  return 'removed';
278
338
  }
@@ -96,6 +96,8 @@ export interface RuntimeServicesOptions {
96
96
  /** Host power seam opt-in. Fork mirrors the SDK: non-spawning unavailable-seam
97
97
  * default (idle-power-services.ts); daemon + embedded runtime pass createHostPowerSeam(). */
98
98
  readonly powerSeam?: Parameters<typeof wireIdlePowerAndLiveTurn>[0]['powerSeam'];
99
+ /** Live session id, read per crash-residue sweep so the running session is exempt — see durability-services.ts. */
100
+ readonly currentSessionId?: (() => string | null) | undefined;
99
101
  }
100
102
 
101
103
  export interface RuntimeServices {
@@ -207,6 +209,9 @@ export interface RuntimeServices {
207
209
  readonly codeIndexReindexScheduler: CodeIndexReindexScheduler; // tool-site reindex
208
210
  /** Daily snapshots of every SQLite store this runtime writes, with bounded retention; unref'd timers (mirrors the SDK composition — hosts that tear down a runtime stop() it themselves). */
209
211
  readonly storeSnapshotScheduler: StoreSnapshotScheduler;
212
+ readonly appendOnlyRetentionScheduler: ReturnType<typeof createDurabilityServices>['appendOnlyRetentionScheduler']; // periodic append-only sweep; unref'd timers, stop() on teardown
213
+ /** Stops the recurring crash-residue sweep; idempotent, unref'd timer (hosts that tear a runtime down call it). */
214
+ readonly stopDurabilityHousekeeping: () => void;
210
215
  readonly memoryConsolidationScheduler: MemoryConsolidationScheduler;
211
216
  readonly powerManager: PowerManager;
212
217
  /** The daemon's memory governor (default ON). Backs ops.memory.get and defends the daemon's footprint by tier. */
@@ -566,10 +571,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
566
571
  // with MemoryStore above. Auto-build is config-gated (default off) — see
567
572
  // code-index-services.ts's header doc.
568
573
  const { codeIndexStore, codeIndexReindexScheduler } = createCodeIndexServices({ workingDirectory, configManager, memoryEmbeddingRegistry, isReindexPaused: () => pauseController.isPaused('code-index-reindex'), admitExpensiveWork });
569
- // Store snapshots + durable remembered-approval rules + the live credential
570
- // chain, mirroring the SDK composition — see durability-services.ts.
571
- const { storeSnapshotScheduler, userPermissionRuleStore } = createDurabilityServices({
574
+ // Store snapshots, the periodic append-only sweep, durable remembered-approval rules + the live credential chain — see durability-services.ts.
575
+ const { storeSnapshotScheduler, appendOnlyRetentionScheduler, userPermissionRuleStore, stopDurabilityHousekeeping } = createDurabilityServices({
572
576
  configManager, secretsManager, providerRegistry, memoryDbPath, codeIndexDbPath: codeIndexDbPath(workingDirectory), surface, shellPaths, // + retention-sweep roots & live config watch (mirrors the SDK)
577
+ ...(options.currentSessionId ? { currentSessionId: options.currentSessionId } : {}), // exempts the running session from crash-residue reaping
573
578
  });
574
579
  const codeInjectionOrchestratorDeps = { codeIndex: codeIndexStore, isCodeInjectionSettingEnabled: () => isCodeInjectionSettingEnabled(configManager), codeIndexReindexScheduler }; // Code-injection seam (agent here; main via orchestrator-core-services.ts)
575
580
  const { processRegistry } = createFleetServices({ // Shared archive-aware fleet registry (+ daemon observed rows) — see fleet-services.ts
@@ -778,7 +783,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
778
783
  workstreamCommands,
779
784
  codeIndexStore,
780
785
  codeIndexReindexScheduler,
781
- storeSnapshotScheduler,
786
+ storeSnapshotScheduler, appendOnlyRetentionScheduler, stopDurabilityHousekeeping,
782
787
  memoryConsolidationScheduler,
783
788
  powerManager,
784
789
  memoryGovernor,
@@ -24,7 +24,7 @@
24
24
  * recovery snapshots they describe — mirrors the transcript-journal.ts
25
25
  * convention (homeDirectory-scoped, under the surface's own directory).
26
26
  */
27
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
27
+ import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs';
28
28
  import { dirname, join } from 'node:path';
29
29
  import type { SessionSurface } from '@/runtime/index.ts';
30
30
 
@@ -42,9 +42,14 @@ export interface LivenessMarker {
42
42
  */
43
43
  export const LIVENESS_STALE_AFTER_MS = 150_000;
44
44
 
45
+ /** Absolute path to the directory holding every session's liveness marker. */
46
+ export function livenessMarkerDirFor(surface: SessionSurface): string {
47
+ return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, 'liveness');
48
+ }
49
+
45
50
  /** Absolute path to a session's liveness marker file. */
46
51
  export function livenessMarkerPathFor(surface: SessionSurface, sessionId: string): string {
47
- return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, 'liveness', `${sessionId}.json`);
52
+ return join(livenessMarkerDirFor(surface), `${sessionId}.json`);
48
53
  }
49
54
 
50
55
  /** Refresh (creating if needed) the liveness marker for this session/pid. Best-effort — never throws. */
@@ -69,18 +74,27 @@ export function removeLivenessMarker(surface: SessionSurface, sessionId: string)
69
74
  }
70
75
  }
71
76
 
72
- function readLivenessMarker(surface: SessionSurface, sessionId: string): LivenessMarker | null {
77
+ /** Parse one marker file by absolute path. Returns null for missing, empty, torn, or wrong-shaped content. */
78
+ function parseMarkerFile(path: string): LivenessMarker | null {
73
79
  try {
74
- const path = livenessMarkerPathFor(surface, sessionId);
75
80
  if (!existsSync(path)) return null;
76
- const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<LivenessMarker>;
81
+ const text = readFileSync(path, 'utf-8');
82
+ // A crash between create and write leaves a zero-byte file. Content, not
83
+ // existence, decides whether this marker says anything at all.
84
+ if (text.trim().length === 0) return null;
85
+ const parsed = JSON.parse(text) as Partial<LivenessMarker>;
77
86
  if (typeof parsed.sessionId !== 'string' || typeof parsed.pid !== 'number' || typeof parsed.updatedAt !== 'number') return null;
87
+ if (!Number.isFinite(parsed.pid) || !Number.isFinite(parsed.updatedAt)) return null;
78
88
  return { sessionId: parsed.sessionId, pid: parsed.pid, updatedAt: parsed.updatedAt };
79
89
  } catch {
80
90
  return null;
81
91
  }
82
92
  }
83
93
 
94
+ function readLivenessMarker(surface: SessionSurface, sessionId: string): LivenessMarker | null {
95
+ return parseMarkerFile(livenessMarkerPathFor(surface, sessionId));
96
+ }
97
+
84
98
  /** Best-effort check that a pid still refers to a running process. Injectable for tests. */
85
99
  export function isPidAlive(pid: number, kill: (pid: number, signal: 0) => void = process.kill.bind(process)): boolean {
86
100
  try {
@@ -118,3 +132,126 @@ export function checkSessionLiveness(
118
132
  if (!aliveCheck(marker.pid)) return { live: false, pid: null };
119
133
  return { live: true, pid: marker.pid };
120
134
  }
135
+
136
+ // ─── Reaping ────────────────────────────────────────────────────────────────
137
+ //
138
+ // `removeLivenessMarker` only runs on a CLEAN exit (process-lifecycle.ts). A
139
+ // SIGKILL, a panic, or a pulled power cord leaves the marker behind forever,
140
+ // and `checkSessionLiveness` treating it as not-live does not remove the file
141
+ // — so the liveness directory grows one file per session id ever opened. The
142
+ // sweep below is the reclaim half of that lifecycle.
143
+
144
+ /**
145
+ * Hard ceiling on marker files kept after a sweep, oldest dropped first.
146
+ *
147
+ * The liveness rule below already reclaims ordinary crash residue, but it can
148
+ * be defeated: `isPidAlive` deliberately reports EPERM (a pid this user cannot
149
+ * signal) as alive, so on a busy host a reused pid can keep a dead session's
150
+ * marker "live" indefinitely. This cap is the second, unconditional bound so
151
+ * the directory can never grow without limit whatever the pid probe says.
152
+ */
153
+ export const LIVENESS_MARKER_MAX_FILES = 200;
154
+
155
+ export interface LivenessReapResult {
156
+ /** Marker files examined this sweep. */
157
+ readonly scanned: number;
158
+ /** Marker files deleted this sweep. */
159
+ readonly reaped: number;
160
+ }
161
+
162
+ export interface LivenessReapOptions {
163
+ readonly now?: () => number;
164
+ readonly isPidAliveFn?: typeof isPidAlive;
165
+ /**
166
+ * Session ids that must never be reaped regardless of what the marker says
167
+ * — normally just the current session's id. Belt-and-braces: the current
168
+ * session refreshes its own marker every 60s, so the liveness rule already
169
+ * keeps it.
170
+ */
171
+ readonly keepSessionIds?: readonly string[];
172
+ /** Override the count cap (tests). */
173
+ readonly maxFiles?: number;
174
+ }
175
+
176
+ /**
177
+ * Delete liveness markers that no longer describe a running session.
178
+ *
179
+ * A marker is reaped when it is either:
180
+ * - unreadable, empty, or not shaped like a marker (crash residue that can
181
+ * never resolve to a live session again — validated by parsing the
182
+ * content, not by the file merely existing); or
183
+ * - definitively not live: older than `LIVENESS_STALE_AFTER_MS` AND its pid
184
+ * no longer resolves to a running process.
185
+ *
186
+ * Both halves of that second rule are required on purpose. Deleting is
187
+ * destructive while a liveness answer is only advisory, so a marker that is
188
+ * stale-but-pid-alive (a wedged owner still holding the session) or
189
+ * fresh-but-pid-dead (a process that died seconds ago, or a pid probe that
190
+ * failed transiently) is left for a later sweep. A genuinely crashed session
191
+ * satisfies both conditions within `LIVENESS_STALE_AFTER_MS` and is reclaimed
192
+ * on the next sweep after that.
193
+ *
194
+ * Idempotent and safe to run concurrently from several processes: a marker
195
+ * another sweeper unlinked between the directory listing and this unlink
196
+ * (ENOENT) counts as reaped, never as an error.
197
+ */
198
+ export function reapStaleLivenessMarkers(surface: SessionSurface, opts: LivenessReapOptions = {}): LivenessReapResult {
199
+ const dir = livenessMarkerDirFor(surface);
200
+ let names: string[];
201
+ try {
202
+ names = readdirSync(dir).filter((name) => name.endsWith('.json'));
203
+ } catch {
204
+ // No liveness directory yet, or it is unreadable — nothing to reclaim.
205
+ return { scanned: 0, reaped: 0 };
206
+ }
207
+
208
+ const now = opts.now?.() ?? Date.now();
209
+ const aliveCheck = opts.isPidAliveFn ?? isPidAlive;
210
+ const keep = new Set(opts.keepSessionIds ?? []);
211
+ const maxFiles = opts.maxFiles ?? LIVENESS_MARKER_MAX_FILES;
212
+
213
+ let reaped = 0;
214
+ /** Survivors, so the count cap below can drop the oldest of them. */
215
+ const survivors: { readonly path: string; readonly updatedAt: number }[] = [];
216
+
217
+ for (const name of names) {
218
+ const sessionId = name.slice(0, -'.json'.length);
219
+ if (keep.has(sessionId)) continue;
220
+ const path = join(dir, name);
221
+ const marker = parseMarkerFile(path);
222
+ if (marker === null) {
223
+ if (unlinkIfPresent(path)) reaped++;
224
+ continue;
225
+ }
226
+ const stale = now - marker.updatedAt > LIVENESS_STALE_AFTER_MS;
227
+ if (stale && !aliveCheck(marker.pid)) {
228
+ if (unlinkIfPresent(path)) reaped++;
229
+ continue;
230
+ }
231
+ survivors.push({ path, updatedAt: marker.updatedAt });
232
+ }
233
+
234
+ if (survivors.length > maxFiles) {
235
+ survivors.sort((a, b) => a.updatedAt - b.updatedAt); // oldest first
236
+ for (const victim of survivors.slice(0, survivors.length - maxFiles)) {
237
+ if (unlinkIfPresent(victim.path)) reaped++;
238
+ }
239
+ }
240
+
241
+ return { scanned: names.length, reaped };
242
+ }
243
+
244
+ /**
245
+ * Best-effort unlink shared by the sweep. Returns true when the file is gone
246
+ * because of (or by the time of) this call. A concurrent sweeper winning the
247
+ * race (ENOENT) is success, not failure; any other error leaves the file for
248
+ * the next sweep.
249
+ */
250
+ function unlinkIfPresent(path: string): boolean {
251
+ try {
252
+ unlinkSync(path);
253
+ return true;
254
+ } catch (err) {
255
+ return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
256
+ }
257
+ }