@pellux/goodvibes-tui 1.20.1 → 1.21.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +106 -22
  4. package/package.json +7 -7
  5. package/src/cli/doctor.ts +7 -1
  6. package/src/cli/package-verification.ts +92 -7
  7. package/src/cli/status.ts +16 -0
  8. package/src/config/read-versioned.ts +115 -2
  9. package/src/core/conversation-event-navigation.ts +42 -0
  10. package/src/core/conversation-search-expansion.ts +67 -0
  11. package/src/core/conversation-splash-state.ts +58 -0
  12. package/src/core/conversation.ts +23 -23
  13. package/src/core/model-routing-chip.ts +38 -18
  14. package/src/core/rewind-turn-anchors.ts +155 -3
  15. package/src/core/stream-event-wiring.ts +141 -20
  16. package/src/core/transcript-journal.ts +145 -7
  17. package/src/daemon/handlers/inbox/cursor-store.ts +159 -3
  18. package/src/daemon/handlers/inbox/index.ts +14 -1
  19. package/src/daemon/handlers/sqlite-store.ts +160 -6
  20. package/src/input/command-registry.ts +8 -0
  21. package/src/input/commands/local-runtime.ts +6 -1
  22. package/src/input/commands/schedule-runtime.ts +15 -2
  23. package/src/input/commands/shell-core.ts +87 -38
  24. package/src/input/composer-capture.ts +47 -0
  25. package/src/input/handler-command-route.ts +4 -0
  26. package/src/input/handler-content-actions.ts +9 -0
  27. package/src/input/handler-feed-routes.ts +1 -0
  28. package/src/input/handler-modal-routes.ts +12 -1
  29. package/src/input/handler-picker-routes.ts +43 -5
  30. package/src/input/handler-shortcuts.ts +1 -1
  31. package/src/input/handler-ui-state.ts +8 -3
  32. package/src/input/model-picker-items.ts +17 -2
  33. package/src/input/model-picker.ts +19 -2
  34. package/src/input/search.ts +183 -89
  35. package/src/input/settings-modal-types.ts +2 -1
  36. package/src/main.ts +12 -20
  37. package/src/providers/reasoning-effort-surface.ts +399 -0
  38. package/src/renderer/compositor.ts +68 -18
  39. package/src/renderer/markdown-inline.ts +143 -0
  40. package/src/renderer/markdown-table.ts +459 -0
  41. package/src/renderer/markdown.ts +6 -335
  42. package/src/renderer/model-workspace.ts +48 -5
  43. package/src/renderer/settings-modal-helpers.ts +53 -1
  44. package/src/renderer/settings-modal.ts +1 -50
  45. package/src/runtime/bootstrap-command-parts.ts +36 -4
  46. package/src/runtime/bootstrap-core.ts +1 -1
  47. package/src/runtime/durability-housekeeping.ts +183 -0
  48. package/src/runtime/durability-services.ts +68 -11
  49. package/src/runtime/legacy-daemon-reconcile.ts +16 -7
  50. package/src/runtime/provider-fallback.ts +16 -2
  51. package/src/runtime/recovery-decisions.ts +180 -11
  52. package/src/runtime/recovery-prompt.ts +11 -1
  53. package/src/runtime/services.ts +9 -4
  54. package/src/runtime/session-liveness-marker.ts +142 -5
  55. package/src/runtime/workstream-draft-store.ts +198 -25
  56. package/src/runtime/workstream-services.ts +17 -2
  57. package/src/shell/ui-openers.ts +1 -1
  58. package/src/version.ts +1 -1
  59. package/src/work-plans/work-plan-store.ts +334 -16
@@ -0,0 +1,183 @@
1
+ /**
2
+ * durability-housekeeping.ts — the reclaim half of every crash-durability
3
+ * artefact this product writes to disk.
4
+ *
5
+ * The standing rule this module implements: if something is persisted across
6
+ * restarts and crashes, recovery has to do the housekeeping. Each individual
7
+ * store already knew how to WRITE its artefact and how to retire it on a clean
8
+ * path; none of them reclaimed what a SIGKILL left behind. Four stores, four
9
+ * kinds of residue:
10
+ *
11
+ * - liveness markers `<home>/.goodvibes/<root>/liveness/<id>.json`
12
+ * removed only on a clean exit, so a crash leaks one
13
+ * file per session id ever opened.
14
+ * - transcript journals `<home>/.goodvibes/<root>/transcript-<id>.journal`
15
+ * deleted on snapshot or replay, so a session that
16
+ * crashes and is never resumed leaks its journal.
17
+ * - quarantine files `<path>.unrecognized` — written so a human can
18
+ * inspect a corrupt file, never removed afterwards.
19
+ * - anchor sidecars `<sessionsDir>/<id>.anchors.json` — outlives the
20
+ * session JSONL that owns it.
21
+ *
22
+ * Every reap here is bounded by BOTH an age window and a count cap, decides
23
+ * what to keep by reading content rather than by a file merely existing, never
24
+ * touches the current session, tolerates a competing sweeper deleting the same
25
+ * file first, and reports what it removed. `startDurabilityHousekeeping` runs
26
+ * the sweep at startup and then on a repeating timer, because a long-lived
27
+ * process that only sweeps at boot never sweeps.
28
+ *
29
+ * Best-effort throughout: a failure in one reap never aborts the others and
30
+ * nothing here ever throws to its caller.
31
+ */
32
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
33
+ import { join } from 'node:path';
34
+ import { reapQuarantinedFiles } from '@/config/read-versioned.ts';
35
+ import { reapOrphanedAnchorSidecars } from '@/core/rewind-turn-anchors.ts';
36
+ import { reapOrphanedJournals } from '@/core/transcript-journal.ts';
37
+ import { checkSessionLiveness, reapStaleLivenessMarkers } from '@/runtime/session-liveness-marker.ts';
38
+ import type { SessionSurface } from '@/runtime/index.ts';
39
+
40
+ /**
41
+ * How often the sweep repeats after startup: 6 hours. Hours, not minutes — the
42
+ * residue this reclaims accumulates per crashed session, not continuously, and
43
+ * a directory scan is not something a foreground TUI should be doing often.
44
+ */
45
+ export const DURABILITY_HOUSEKEEPING_INTERVAL_MS = 6 * 60 * 60 * 1000;
46
+
47
+ export interface DurabilityHousekeepingInput {
48
+ /** The app's declare-once storage handle; every swept path derives from it. */
49
+ readonly surface: SessionSurface;
50
+ /**
51
+ * The session this process is using right now, read at sweep time rather
52
+ * than captured once — a resume or fork can repoint it between sweeps.
53
+ * Returning null means "not known yet", which is safe: the age and liveness
54
+ * rules independently protect an actively written artefact.
55
+ */
56
+ readonly currentSessionId?: () => string | null;
57
+ /** Extra directories to scan for `.unrecognized` quarantine files. */
58
+ readonly extraQuarantineDirs?: readonly string[];
59
+ }
60
+
61
+ export interface DurabilityHousekeepingOutcome {
62
+ readonly livenessMarkers: number;
63
+ readonly transcriptJournals: number;
64
+ readonly quarantineFiles: number;
65
+ readonly anchorSidecars: number;
66
+ /** Sum of the four counts above — zero means nothing was reclaimed. */
67
+ readonly total: number;
68
+ }
69
+
70
+ /**
71
+ * Run all four reaps once and return what each reclaimed.
72
+ *
73
+ * Each reap is isolated: one throwing is logged at warn level and counted as
74
+ * zero, and the remaining reaps still run. The caller never sees an exception.
75
+ */
76
+ export function runDurabilityHousekeeping(input: DurabilityHousekeepingInput): DurabilityHousekeepingOutcome {
77
+ const { surface } = input;
78
+ const currentSessionId = safely('current session id', () => input.currentSessionId?.() ?? null, null);
79
+
80
+ const livenessMarkers = safely('liveness markers', () =>
81
+ reapStaleLivenessMarkers(surface, {
82
+ keepSessionIds: currentSessionId ? [currentSessionId] : [],
83
+ }).reaped, 0);
84
+
85
+ const transcriptJournals = safely('transcript journals', () =>
86
+ reapOrphanedJournals(surface, {
87
+ currentSessionId,
88
+ // Composition happens here so transcript-journal.ts keeps no dependency
89
+ // on the liveness marker: a journal whose session is open in another
90
+ // running instance is live state, not residue.
91
+ isSessionLive: (sessionId) => checkSessionLiveness(surface, sessionId).live,
92
+ }).reaped, 0);
93
+
94
+ const quarantineFiles = safely('quarantine files', () =>
95
+ reapQuarantinedFiles(quarantineDirsFor(input)).reaped, 0);
96
+
97
+ const anchorSidecars = safely('anchor sidecars', () =>
98
+ reapOrphanedAnchorSidecars(surface, { currentSessionId }).reaped, 0);
99
+
100
+ const outcome: DurabilityHousekeepingOutcome = {
101
+ livenessMarkers,
102
+ transcriptJournals,
103
+ quarantineFiles,
104
+ anchorSidecars,
105
+ total: livenessMarkers + transcriptJournals + quarantineFiles + anchorSidecars,
106
+ };
107
+ discloseHousekeeping(outcome);
108
+ return outcome;
109
+ }
110
+
111
+ /**
112
+ * Start durability housekeeping: one sweep now, then a repeating sweep every
113
+ * `DURABILITY_HOUSEKEEPING_INTERVAL_MS`. Returns a disposer that stops the
114
+ * timer; calling it more than once is harmless.
115
+ *
116
+ * The timer is `unref()`d, so an undisposed sweeper can never hold the process
117
+ * open — same convention the store-snapshot scheduler and the config-file
118
+ * watchers in durability-services.ts use.
119
+ */
120
+ export function startDurabilityHousekeeping(
121
+ input: DurabilityHousekeepingInput,
122
+ options: { readonly intervalMs?: number } = {},
123
+ ): () => void {
124
+ runDurabilityHousekeeping(input);
125
+ const timer = setInterval(
126
+ () => { runDurabilityHousekeeping(input); },
127
+ options.intervalMs ?? DURABILITY_HOUSEKEEPING_INTERVAL_MS,
128
+ );
129
+ timer.unref?.();
130
+ let stopped = false;
131
+ return () => {
132
+ if (stopped) return;
133
+ stopped = true;
134
+ clearInterval(timer);
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Disclose what was reclaimed — silent deletion is indistinguishable from data
140
+ * loss. Paths and counts only; nothing about a reclaimed file's contents.
141
+ *
142
+ * A sweep that reclaimed nothing logs nothing: the overwhelmingly common boot
143
+ * has no residue at all, and a per-boot "reclaimed 0" line would train readers
144
+ * to ignore the line that actually matters.
145
+ */
146
+ function discloseHousekeeping(outcome: DurabilityHousekeepingOutcome): void {
147
+ if (outcome.total === 0) return;
148
+ logger.info('durability housekeeping reclaimed crash residue', {
149
+ livenessMarkers: outcome.livenessMarkers,
150
+ transcriptJournals: outcome.transcriptJournals,
151
+ quarantineFiles: outcome.quarantineFiles,
152
+ anchorSidecars: outcome.anchorSidecars,
153
+ total: outcome.total,
154
+ });
155
+ }
156
+
157
+ /**
158
+ * Every directory that can accumulate `.unrecognized` files: the home-scoped
159
+ * surface root (transcript-journal quarantines) and the per-project session,
160
+ * state, recovery and checkpoint directories (versioned-config quarantines),
161
+ * plus whatever the caller adds.
162
+ */
163
+ function quarantineDirsFor(input: DurabilityHousekeepingInput): readonly string[] {
164
+ const { surface } = input;
165
+ return [
166
+ join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot),
167
+ surface.sessionsDir,
168
+ surface.stateDir,
169
+ surface.recoveryDir,
170
+ surface.checkpointsDir,
171
+ ...(input.extraQuarantineDirs ?? []),
172
+ ].filter((dir): dir is string => typeof dir === 'string' && dir.length > 0);
173
+ }
174
+
175
+ /** Run one reap in isolation: never throws, returns `fallback` if it would have. */
176
+ function safely<T>(label: string, run: () => T, fallback: T): T {
177
+ try {
178
+ return run();
179
+ } catch (error) {
180
+ logger.warn('durability housekeeping step failed', { step: label, error: summarizeError(error) });
181
+ return fallback;
182
+ }
183
+ }
@@ -18,6 +18,12 @@
18
18
  * EVERY root the composition knows (working dir, surface root, home,
19
19
  * logDir, telemetryDir) — omitting logDir/telemetryDir/home would silently
20
20
  * skip the activity-log, telemetry-ledger, and recovery-snapshot stores.
21
+ * - Durability housekeeping: the crash-residue reclaim the retention janitor
22
+ * above does NOT cover — stale liveness markers, orphaned transcript
23
+ * journals, `.unrecognized` quarantine files, and anchor sidecars whose
24
+ * session is gone (see durability-housekeeping.ts). It runs at startup AND
25
+ * on a repeating unref'd timer, because a long-lived process that only
26
+ * sweeps at boot never sweeps; the returned disposer stops that timer.
21
27
  * - Live config-file watch: external edits to the settings file apply through
22
28
  * the same subscribe() pipeline an in-process set() uses — no restart. The
23
29
  * underlying watchers are unref'd, so this never pins the event loop.
@@ -28,6 +34,7 @@ import { resolveMemoryVectorDbPath } from '@pellux/goodvibes-sdk/platform/state'
28
34
  import { StoreSnapshotScheduler } from '@pellux/goodvibes-sdk/platform/state/store-snapshots';
29
35
  import { UserPermissionRuleStore } from '@pellux/goodvibes-sdk/platform/permissions';
30
36
  import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
37
+ import { startDurabilityHousekeeping } from './durability-housekeeping.ts';
31
38
  import type { SessionSurface } from '@/runtime/index.ts';
32
39
 
33
40
  export interface DurabilityServicesInput {
@@ -48,11 +55,44 @@ export interface DurabilityServicesInput {
48
55
  */
49
56
  readonly surface: SessionSurface;
50
57
  readonly shellPaths: { resolveUserPath(...segments: string[]): string };
58
+ /**
59
+ * The session id this process is currently using, read at each sweep. When
60
+ * omitted the crash-residue reap still protects live artefacts through its
61
+ * age and liveness rules — this is the explicit belt-and-braces guard.
62
+ */
63
+ /**
64
+ * Resolves the live session id, read fresh on every crash-residue sweep so
65
+ * the running session's own transcript journal and liveness marker are
66
+ * exempt from reaping.
67
+ *
68
+ * A getter, not a value: the id is reassigned in place when a recovery
69
+ * snapshot is accepted, and this sweep repeats for the life of the process.
70
+ *
71
+ * Omitting it is not merely untidy. The journal reaper's other guard is the
72
+ * liveness marker, and that marker goes stale after 150 seconds — so a host
73
+ * that passes nothing here is trusting a heartbeat that a single long
74
+ * blocking turn can outrun, and an in-process sweep landing in that window
75
+ * would delete the journal of the session currently writing it. Passing this
76
+ * makes the exemption unconditional instead of timing-dependent.
77
+ */
78
+ readonly currentSessionId?: () => string | null;
51
79
  }
52
80
 
53
81
  export interface DurabilityServices {
54
82
  readonly storeSnapshotScheduler: StoreSnapshotScheduler;
83
+ /**
84
+ * Re-sweeps every registered append-only store on a cadence. The start-time
85
+ * sweep alone never prunes again in a process that stays up, which is the
86
+ * window in which those stores grow. Unref'd timers; teardown stop()s it.
87
+ */
88
+ readonly appendOnlyRetentionScheduler: InstanceType<typeof operations.AppendOnlyRetentionScheduler>;
55
89
  readonly userPermissionRuleStore: UserPermissionRuleStore;
90
+ /**
91
+ * Stops the repeating crash-residue sweep. The timer is unref'd, so a host
92
+ * that never calls this is not held open by it; teardown calls it to stop
93
+ * the work rather than to release the loop.
94
+ */
95
+ readonly stopDurabilityHousekeeping: () => void;
56
96
  }
57
97
 
58
98
  export function createDurabilityServices(input: DurabilityServicesInput): DurabilityServices {
@@ -73,16 +113,33 @@ export function createDurabilityServices(input: DurabilityServicesInput): Durabi
73
113
  // activity-log, telemetry-ledger, and recovery-snapshot stores are all swept.
74
114
  // Best-effort — a retention failure never takes startup down (the sweep
75
115
  // swallows its own errors).
76
- operations.runStartupAppendOnlySweep(
77
- {
78
- workingDirectory: input.surface.workingDirectory,
79
- surfaceRoot: input.surface.surfaceRoot,
80
- homeDirectory: input.surface.homeDirectory,
81
- logDir: input.shellPaths.resolveUserPath('logs'),
82
- telemetryDir: input.shellPaths.resolveUserPath('telemetry'),
83
- },
84
- (k: string) => configManager.get(k as never),
85
- );
116
+ const appendOnlyRetentionRoots = {
117
+ workingDirectory: input.surface.workingDirectory,
118
+ surfaceRoot: input.surface.surfaceRoot,
119
+ homeDirectory: input.surface.homeDirectory,
120
+ logDir: input.shellPaths.resolveUserPath('logs'),
121
+ telemetryDir: input.shellPaths.resolveUserPath('telemetry'),
122
+ };
123
+ const appendOnlyRetentionConfigGet = (k: string): unknown => configManager.get(k as never);
124
+ operations.runStartupAppendOnlySweep(appendOnlyRetentionRoots, appendOnlyRetentionConfigGet);
125
+ // ...and again on a cadence for as long as this process lives. A start-time
126
+ // sweep alone never prunes a long-lived session again after boot, which is
127
+ // exactly the window in which these stores grow. Unref'd timers; the disposer
128
+ // below stops it, same posture as storeSnapshotScheduler.
129
+ const appendOnlyRetentionScheduler = new operations.AppendOnlyRetentionScheduler({
130
+ roots: appendOnlyRetentionRoots,
131
+ configGet: appendOnlyRetentionConfigGet,
132
+ });
133
+ appendOnlyRetentionScheduler.start();
134
+ // Crash-residue reclaim the sweep above does not cover (liveness markers,
135
+ // orphaned transcript journals, .unrecognized quarantine files, anchor
136
+ // sidecars whose session file is gone). Runs once now and every few hours
137
+ // after that; the timer is unref'd and the disposer stops it.
138
+ const stopDurabilityHousekeeping = startDurabilityHousekeeping({
139
+ surface: input.surface,
140
+ currentSessionId: input.currentSessionId,
141
+ extraQuarantineDirs: [configManager.getControlPlaneConfigDir()],
142
+ });
86
143
  // External config edits apply LIVE through the same subscribe() pipeline an
87
144
  // in-process set() uses; the underlying watchers are unref'd.
88
145
  configManager.watchConfigFiles();
@@ -90,5 +147,5 @@ export function createDurabilityServices(input: DurabilityServicesInput): Durabi
90
147
  const userPermissionRuleStore = new UserPermissionRuleStore(join(configManager.getControlPlaneConfigDir(), 'permission-rules.json'));
91
148
  void userPermissionRuleStore.init().catch((error) => logger.warn('user permission rule store init failed; asks will prompt', { error: summarizeError(error) }));
92
149
 
93
- return { storeSnapshotScheduler, userPermissionRuleStore };
150
+ return { storeSnapshotScheduler, appendOnlyRetentionScheduler, userPermissionRuleStore, stopDurabilityHousekeeping };
94
151
  }
@@ -101,6 +101,14 @@ export interface ReconcileRedundantLegacyUnitInput {
101
101
  readonly readOwnCgroup?: (() => string) | undefined;
102
102
  /** Timeout applied to the DEFAULT systemctl runner only. Defaults to SYSTEMCTL_TIMEOUT_MS. */
103
103
  readonly systemctlTimeoutMs?: number | undefined;
104
+ /**
105
+ * Program the DEFAULT runner executes. Defaults to `systemctl` (resolved on
106
+ * PATH). Tests that deliberately exercise the default (non-injected) runner
107
+ * point this at an absolute stub path: Bun resolves a spawned program from
108
+ * the PATH captured at process start, so mutating `process.env.PATH` inside
109
+ * a test cannot redirect the call and the host's real systemctl would answer.
110
+ */
111
+ readonly systemctlCommand?: string | undefined;
104
112
  /** Cumulative budget for the whole pass. Defaults to RECONCILE_DEADLINE_MS. */
105
113
  readonly deadlineMs?: number | undefined;
106
114
  }
@@ -201,6 +209,7 @@ export async function reconcileRedundantLegacyUnit(
201
209
  input.systemctlTimeoutMs ?? SYSTEMCTL_TIMEOUT_MS,
202
210
  input.deadlineMs ?? RECONCILE_DEADLINE_MS,
203
211
  );
212
+ const systemctl = input.systemctlCommand ?? 'systemctl';
204
213
  const canonicalUnit = `${input.trackedServiceName}.service`;
205
214
  const legacyUnit = `${LEGACY_SERVICE_UNIT_NAME}.service`;
206
215
  const deadlineNote = (): string[] =>
@@ -210,7 +219,7 @@ export async function reconcileRedundantLegacyUnit(
210
219
 
211
220
  // Guard 1: the canonical unit must report active. (A timed-out, skipped, or
212
221
  // failed probe — e.g. a wedged user bus — lands here too and refuses.)
213
- const probe = run('systemctl', ['--user', 'is-active', canonicalUnit]);
222
+ const probe = run(systemctl, ['--user', 'is-active', canonicalUnit]);
214
223
  const canonicalActive = (probe.status ?? 1) === 0 && (probe.stdout ?? '').trim() === 'active';
215
224
  if (!canonicalActive) {
216
225
  return {
@@ -227,7 +236,7 @@ export async function reconcileRedundantLegacyUnit(
227
236
  // Guard 2: 'active' alone does not prove the canonical unit is the daemon
228
237
  // actually serving (Type=simple reports active from fork onward). Require
229
238
  // its MainPID to resolve and be a live process.
230
- const canonicalPid = parseMainPid(run('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', canonicalUnit]));
239
+ const canonicalPid = parseMainPid(run(systemctl, ['--user', 'show', '-p', 'MainPID', '--value', canonicalUnit]));
231
240
  const alive = input.processAlive ?? defaultProcessAlive;
232
241
  if (canonicalPid === undefined || !alive(canonicalPid)) {
233
242
  return {
@@ -253,7 +262,7 @@ export async function reconcileRedundantLegacyUnit(
253
262
  // it: in the wrong-port state the legacy daemon itself answers the
254
263
  // configured-endpoint probe). Unknown refuses.
255
264
  const ownPid = input.ownPid ?? process.pid;
256
- const legacyPidReply = run('systemctl', ['--user', 'show', '-p', 'MainPID', '--value', legacyUnit]);
265
+ const legacyPidReply = run(systemctl, ['--user', 'show', '-p', 'MainPID', '--value', legacyUnit]);
257
266
  const legacyPidKnown = (legacyPidReply.status ?? 1) === 0;
258
267
  const legacyPid = parseMainPid(legacyPidReply);
259
268
  const ownCgroup = (input.readOwnCgroup ?? defaultReadOwnCgroup)();
@@ -359,7 +368,7 @@ export async function reconcileRedundantLegacyUnit(
359
368
  // unit file if the disable actually succeeded — otherwise the enablement
360
369
  // symlink dangles at a deleted file and this tool can never repair it (the
361
370
  // next pass no-ops at the file-exists check).
362
- const disableResult = run('systemctl', ['--user', 'disable', '--now', legacyUnit]);
371
+ const disableResult = run(systemctl, ['--user', 'disable', '--now', legacyUnit]);
363
372
  const disableStatus = disableResult.status ?? 1;
364
373
  if (disableStatus !== 0) {
365
374
  if (disableResult.status === null) {
@@ -367,7 +376,7 @@ export async function reconcileRedundantLegacyUnit(
367
376
  // removes the enablement symlinks synchronously BEFORE waiting on the
368
377
  // stop job, so the disable may well have taken effect even though the
369
378
  // client was killed. Re-inspect instead of printing a blanket denial.
370
- const reinspect = run('systemctl', ['--user', 'is-enabled', legacyUnit]);
379
+ const reinspect = run(systemctl, ['--user', 'is-enabled', legacyUnit]);
371
380
  const reinspectOut = (reinspect.stdout ?? '').trim();
372
381
  const confirmedDisabled = (reinspect.status !== null)
373
382
  && (reinspectOut === 'disabled' || reinspectOut === 'not-found');
@@ -381,7 +390,7 @@ export async function reconcileRedundantLegacyUnit(
381
390
  } catch (error) {
382
391
  removeErrorAfterTimeout = summarizeError(error);
383
392
  }
384
- run('systemctl', ['--user', 'daemon-reload']);
393
+ run(systemctl, ['--user', 'daemon-reload']);
385
394
  const lines = [
386
395
  `reconciled: the disable command timed out waiting on the stop job, but re-inspection confirms the ` +
387
396
  `installer-managed ${legacyUnit} is no longer enabled${removeErrorAfterTimeout ? '' : ` — its unit file was removed (${path})`}. ` +
@@ -425,7 +434,7 @@ export async function reconcileRedundantLegacyUnit(
425
434
  } catch (error) {
426
435
  removeError = summarizeError(error);
427
436
  }
428
- run('systemctl', ['--user', 'daemon-reload']);
437
+ run(systemctl, ['--user', 'daemon-reload']);
429
438
 
430
439
  const lines = [
431
440
  `reconciled: ${canonicalUnit} is active and serving, so the redundant installer-managed ${legacyUnit} ` +
@@ -6,7 +6,12 @@
6
6
  * compaction denominator agree with the post-catalog window.
7
7
  */
8
8
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
9
- import { inferFallbackContextWindow, type ModelDefinition, type ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
9
+ import {
10
+ inferFallbackContextWindow,
11
+ resolveReasoningEffortSpec,
12
+ type ModelDefinition,
13
+ type ProviderRegistry,
14
+ } from '@pellux/goodvibes-sdk/platform/providers';
10
15
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
11
16
 
12
17
  function buildFallbackModelDefinition(provider: string, modelId: string): ModelDefinition {
@@ -32,7 +37,16 @@ function buildFallbackModelDefinition(provider: string, modelId: string): ModelD
32
37
  contextWindowProvenance: 'fallback',
33
38
  selectable: true,
34
39
  tier: 'standard',
35
- ...(isReasoningProvider ? { reasoningEffort: ['instant', 'low', 'medium', 'high'] } : {}),
40
+ // Which levels this model accepts is a property of the model, not of the
41
+ // provider it sits behind: the hardcoded four ('instant', 'low', 'medium',
42
+ // 'high') offered 'instant' to models that reject it and hid 'xhigh',
43
+ // 'max' and 'none' from models that accept them. The SDK resolver answers
44
+ // from the curated family table when the catalog has not loaded yet, and
45
+ // otherwise returns its own labelled best guess, which the adapters treat
46
+ // as "send nothing" rather than as verified levels.
47
+ ...(isReasoningProvider
48
+ ? { reasoningEffort: resolveReasoningEffortSpec({ modelId }) }
49
+ : {}),
36
50
  };
37
51
  }
38
52
 
@@ -30,8 +30,9 @@
30
30
  * another. Each record still carries the workspace it was made in, so the file
31
31
  * says plainly where the decision came from.
32
32
  */
33
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
33
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
34
34
  import { dirname, join } from 'node:path';
35
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
35
36
  import type { SessionSurface } from '@/runtime/index.ts';
36
37
 
37
38
  /** One "the user removed this snapshot" decision. */
@@ -52,6 +53,12 @@ export interface RecoveryRemovalRecord {
52
53
  * deleted, so that rule would empty the ledger immediately and reinstate the
53
54
  * exact defect it was written to fix — the point is precisely to still
54
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.
55
62
  */
56
63
  const MAX_RECORDS = 200;
57
64
  const RECORD_TTL_MS = 90 * 24 * 60 * 60 * 1000;
@@ -71,22 +78,162 @@ function isRecord(value: unknown): value is RecoveryRemovalRecord {
71
78
  && Number.isFinite(candidate.removedAt);
72
79
  }
73
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
+
74
124
  /**
75
125
  * Every removal decision still in force, oldest first. A missing, unreadable
76
126
  * or malformed ledger reads as "no decisions recorded" rather than throwing:
77
127
  * a corrupt file must not be able to take a boot down, and the worst outcome
78
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}.
79
133
  */
80
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 });
81
152
  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);
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);
87
222
  } catch {
88
- return [];
223
+ // The in-memory filter already ignores these records; failing to persist
224
+ // the prune costs disk space, not correctness.
225
+ return outcome;
89
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;
90
237
  }
91
238
 
92
239
  /**
@@ -111,15 +258,37 @@ export function isRecoveryRemovalRecorded(surface: SessionSurface, sessionId: st
111
258
  */
112
259
  export function recordRecoveryRemoval(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): void {
113
260
  if (!sessionId.trim()) return;
261
+ const path = recoveryDecisionsPathFor(surface);
114
262
  try {
115
- const kept = readRecoveryRemovals(surface, nowMs).filter((record) => record.sessionId !== sessionId);
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);
116
274
  kept.push({ sessionId, workspace: surface.workingDirectory, removedAt: nowMs });
117
275
  // Oldest decisions fall off first; the newest are the ones a reappearing
118
276
  // snapshot is most likely to match.
277
+ const overCap = Math.max(0, kept.length - MAX_RECORDS);
119
278
  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 });
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
+ }
123
292
  } catch {
124
293
  // Best-effort by construction — see the doc comment above.
125
294
  }