@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
@@ -21,7 +21,7 @@
21
21
  * `messageCount` boundaries stay valid because a resume rehydrates the same
22
22
  * message history the anchors were recorded against.
23
23
  */
24
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
24
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  import type { SessionSurface } from '@/runtime/index.ts';
27
27
 
@@ -92,12 +92,14 @@ export function clearTurnAnchors(sessionId: string): void {
92
92
  const ANCHOR_SIDECAR_VERSION = 1;
93
93
 
94
94
  /** Absolute path to a session's anchor sidecar, or null when inputs are unusable. */
95
+ const ANCHOR_SIDECAR_SUFFIX = '.anchors.json';
96
+
95
97
  function anchorSidecarPath(sessionId: string, surface: SessionSurface): string | null {
96
98
  if (!sessionId || !surface.sessionsDir) return null;
97
99
  // A sessionId is a filename component; refuse anything with path separators so
98
100
  // a malformed id can never escape the sessions directory.
99
101
  if (/[\\/]/.test(sessionId)) return null;
100
- return join(surface.sessionsDir, `${sessionId}.anchors.json`);
102
+ return join(surface.sessionsDir, `${sessionId}${ANCHOR_SIDECAR_SUFFIX}`);
101
103
  }
102
104
 
103
105
  function isTurnAnchor(value: unknown): value is TurnAnchor {
@@ -125,7 +127,7 @@ export function persistTurnAnchors(sessionId: string, surface: SessionSurface):
125
127
  try {
126
128
  mkdirSync(surface.sessionsDir, { recursive: true });
127
129
  const payload = JSON.stringify({ version: ANCHOR_SIDECAR_VERSION, sessionId, anchors: list });
128
- const tmp = `${path}.tmp-${process.pid}`;
130
+ const tmp = `${path}${ANCHOR_SIDECAR_TMP_MARKER}${process.pid}`;
129
131
  writeFileSync(tmp, payload);
130
132
  renameSync(tmp, path);
131
133
  } catch {
@@ -157,3 +159,153 @@ export function restoreTurnAnchors(sessionId: string, surface: SessionSurface):
157
159
  return 0;
158
160
  }
159
161
  }
162
+
163
+ // ─── Reaping orphaned sidecars ────────────────────────────────────────────────
164
+ //
165
+ // A sidecar's OWNER is the session JSONL it sits beside. Deleting a session
166
+ // removes the JSONL but nothing ever removed the sidecar, so anchors for
167
+ // sessions that no longer exist accumulate in the sessions directory forever.
168
+ // The sweep below reclaims them, plus the `.tmp-<pid>` staging files a crash
169
+ // between write and rename leaves behind.
170
+
171
+ /** Infix for the atomic-write staging file, before the writing process's pid. */
172
+ const ANCHOR_SIDECAR_TMP_MARKER = '.tmp-';
173
+
174
+ /**
175
+ * How long a staging file is tolerated before it is treated as crash residue:
176
+ * 1 hour. `persistTurnAnchors` renames within microseconds of writing, so
177
+ * anything this old was interrupted, and the completed sidecar (if the write
178
+ * ever finished) is a separate file.
179
+ */
180
+ export const ANCHOR_TMP_MAX_AGE_MS = 60 * 60 * 1000;
181
+
182
+ /**
183
+ * How settled a sidecar must be before this sweep will delete it: 1 hour.
184
+ *
185
+ * Another instance can be persisting anchors for a session this process knows
186
+ * nothing about, and the window between reading a sidecar's content and
187
+ * unlinking it is not atomic. Requiring the file to have been untouched for an
188
+ * hour means a sidecar that some other instance is actively rewriting is never
189
+ * a candidate, while genuine residue — whose writer is long gone — always is.
190
+ * The sweep repeats, so the delay costs nothing.
191
+ */
192
+ export const ANCHOR_SIDECAR_SETTLE_MS = 60 * 60 * 1000;
193
+
194
+ export interface AnchorSidecarReapResult {
195
+ /** Sidecar and staging files examined this sweep. */
196
+ readonly scanned: number;
197
+ /** Files deleted this sweep. */
198
+ readonly reaped: number;
199
+ }
200
+
201
+ export interface AnchorSidecarReapOptions {
202
+ /** The session this process is using right now; its sidecar is never reaped. */
203
+ readonly currentSessionId?: string | null;
204
+ readonly now?: () => number;
205
+ /** Override the staging-file age window (tests). */
206
+ readonly tmpMaxAgeMs?: number;
207
+ /** Override how long a sidecar must be untouched before it can be reaped (tests). */
208
+ readonly settleMs?: number;
209
+ }
210
+
211
+ /**
212
+ * Delete anchor sidecars whose owning session file is gone, sidecars that hold
213
+ * nothing readable, and abandoned staging files.
214
+ *
215
+ * A sidecar survives when `<sessionsDir>/<sessionId>.jsonl` still exists AND
216
+ * the sidecar itself parses into at least one usable anchor — content, not
217
+ * mere existence, because a sidecar truncated by a crash restores nothing and
218
+ * would otherwise sit there indefinitely looking like valid state. It also
219
+ * survives while it is still fresh (see `ANCHOR_SIDECAR_SETTLE_MS`), which
220
+ * keeps a sidecar another instance is mid-rewrite out of reach.
221
+ *
222
+ * The current session's sidecar is never touched, and an unreadable or absent
223
+ * sessions directory simply reclaims nothing. Idempotent and concurrency-safe:
224
+ * a file another sweeper unlinked first (ENOENT) counts as reaped.
225
+ */
226
+ export function reapOrphanedAnchorSidecars(
227
+ surface: SessionSurface,
228
+ options: AnchorSidecarReapOptions = {},
229
+ ): AnchorSidecarReapResult {
230
+ const dir = surface.sessionsDir;
231
+ if (!dir) return { scanned: 0, reaped: 0 };
232
+
233
+ let names: string[];
234
+ try {
235
+ names = readdirSync(dir).filter(
236
+ (n) => n.endsWith(ANCHOR_SIDECAR_SUFFIX) || n.includes(`${ANCHOR_SIDECAR_SUFFIX}${ANCHOR_SIDECAR_TMP_MARKER}`),
237
+ );
238
+ } catch {
239
+ return { scanned: 0, reaped: 0 };
240
+ }
241
+
242
+ const now = options.now?.() ?? Date.now();
243
+ const tmpMaxAgeMs = options.tmpMaxAgeMs ?? ANCHOR_TMP_MAX_AGE_MS;
244
+ const settleMs = options.settleMs ?? ANCHOR_SIDECAR_SETTLE_MS;
245
+ let reaped = 0;
246
+
247
+ for (const name of names) {
248
+ const path = join(dir, name);
249
+
250
+ if (!name.endsWith(ANCHOR_SIDECAR_SUFFIX)) {
251
+ // A `<sessionId>.anchors.json.tmp-<pid>` staging file. Age alone decides:
252
+ // a young one may belong to another instance's in-flight write.
253
+ let mtimeMs: number;
254
+ try {
255
+ mtimeMs = statSync(path).mtimeMs;
256
+ } catch {
257
+ continue;
258
+ }
259
+ if (now - mtimeMs > tmpMaxAgeMs && unlinkAnchorFile(path)) reaped++;
260
+ continue;
261
+ }
262
+
263
+ const sessionId = name.slice(0, name.length - ANCHOR_SIDECAR_SUFFIX.length);
264
+ if (sessionId.length === 0) continue;
265
+ if (options.currentSessionId && sessionId === options.currentSessionId) continue;
266
+
267
+ // A sidecar written moments ago belongs to a writer that is still around —
268
+ // possibly another instance whose session this process cannot see.
269
+ let sidecarMtimeMs: number;
270
+ try {
271
+ sidecarMtimeMs = statSync(path).mtimeMs;
272
+ } catch {
273
+ continue;
274
+ }
275
+ if (now - sidecarMtimeMs <= settleMs) continue;
276
+
277
+ if (!existsSync(join(dir, `${sessionId}.jsonl`))) {
278
+ // The owning session is gone — this sidecar can never be used again.
279
+ if (unlinkAnchorFile(path)) reaped++;
280
+ continue;
281
+ }
282
+ if (!sidecarHoldsAnchors(path)) {
283
+ // The session survives but the sidecar is empty or torn: it restores
284
+ // nothing, so keeping it only hides the loss.
285
+ if (unlinkAnchorFile(path)) reaped++;
286
+ }
287
+ }
288
+
289
+ return { scanned: names.length, reaped };
290
+ }
291
+
292
+ /** Content check: does this sidecar parse into at least one usable anchor? */
293
+ function sidecarHoldsAnchors(path: string): boolean {
294
+ try {
295
+ const text = readFileSync(path, 'utf8');
296
+ if (text.trim().length === 0) return false;
297
+ const parsed = JSON.parse(text) as { anchors?: unknown };
298
+ return Array.isArray(parsed?.anchors) && parsed.anchors.some(isTurnAnchor);
299
+ } catch {
300
+ return false;
301
+ }
302
+ }
303
+
304
+ function unlinkAnchorFile(path: string): boolean {
305
+ try {
306
+ unlinkSync(path);
307
+ return true;
308
+ } catch (err) {
309
+ return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
310
+ }
311
+ }
@@ -1,10 +1,15 @@
1
1
  import type { UiRuntimeEvents } from '@/runtime/index.ts';
2
2
  import { createStreamStallWatchdog } from './stream-stall-watchdog.ts';
3
- import { buildRoutingChip } from './model-routing-chip.ts';
3
+ import { buildRoutingChip, FALLBACK_CORRELATION_WINDOW_MS } from './model-routing-chip.ts';
4
4
  import { formatUserFacingErrorLine } from './format-user-error.ts';
5
5
  import { classifyProviderSetup } from '../providers/provider-classification.ts';
6
6
  import type { FailoverTurnState } from './active-model-identity.ts';
7
7
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
8
+ import type { ReasoningEffortSpec } from '@pellux/goodvibes-sdk/platform/providers';
9
+ import {
10
+ publishActiveEffortOptions,
11
+ remapEffortForServingModel,
12
+ } from '../providers/reasoning-effort-surface.ts';
8
13
 
9
14
  /**
10
15
  * Live stream and tool-execution metrics maintained by wireStreamEventMetrics.
@@ -75,7 +80,20 @@ interface StreamOrchestrator {
75
80
 
76
81
  /** Minimal provider surface required for the stream stall watchdog and failover switching. */
77
82
  interface StreamProviderRegistry {
78
- getCurrentModel(): { readonly provider: string; readonly registryKey?: string };
83
+ getCurrentModel(): {
84
+ readonly provider: string;
85
+ readonly registryKey?: string;
86
+ /**
87
+ * Model id, display name and reasoning-effort spec, all optional so a test
88
+ * double carrying only provider/registryKey still satisfies this surface.
89
+ * When the id is present the failover path can re-resolve the configured
90
+ * reasoning level against the model that is about to serve; when it is
91
+ * absent that step is skipped rather than guessed at.
92
+ */
93
+ readonly id?: string;
94
+ readonly displayName?: string;
95
+ readonly reasoningEffort?: ReasoningEffortSpec | undefined;
96
+ };
79
97
  setCurrentModel(registryKey: string): void;
80
98
  }
81
99
 
@@ -183,8 +201,14 @@ export interface WireStreamEventMetricsOptions {
183
201
  * rather than emitted before: the caller re-posts it after its rollback, and
184
202
  * it survives to be read. Implementations that do not roll back may ignore
185
203
  * the argument, but must then post the notice themselves.
204
+ *
205
+ * Returns whether the turn was actually re-submitted. False means there was
206
+ * nothing to retry (no pre-submission snapshot — the failed turn did not
207
+ * come from the composer) and the notice was NOT posted, so the caller must
208
+ * narrate the switch and surface the error itself rather than let a turn end
209
+ * in silence on a backend the user did not choose.
186
210
  */
187
- readonly retryTurn?: (notice?: string) => void;
211
+ readonly retryTurn?: (notice?: string) => boolean;
188
212
  /**
189
213
  * Optional cost catalog for attaching per-1M-token cost information to
190
214
  * the failover notice. When provided and both models have non-zero pricing,
@@ -222,6 +246,15 @@ export interface WireStreamEventMetricsOptions {
222
246
  * divergence marker is possible, so both are skipped rather than guessed at.
223
247
  */
224
248
  readonly getConfiguredRegistryKey?: () => string | undefined;
249
+ /**
250
+ * The user's configured reasoning level (config `provider.reasoningEffort`).
251
+ * Read at the moment of a failover switch so the level can be re-resolved
252
+ * against the model that is about to serve: a level the configured model
253
+ * offers may not exist on the fallback, and sending it unchanged is how a
254
+ * failover turns into a provider-side 400 the user cannot explain. Omitted
255
+ * means the re-resolution is skipped entirely, never guessed.
256
+ */
257
+ readonly getConfiguredReasoningEffort?: () => string | undefined;
225
258
  }
226
259
 
227
260
  /** Result of wireStreamEventMetrics. */
@@ -336,6 +369,7 @@ export function wireStreamEventMetrics(
336
369
  events, metrics, orchestrator, providerRegistry,
337
370
  systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
338
371
  isApprovalPending, stallThresholdMs, failoverState, getConfiguredRegistryKey,
372
+ getConfiguredReasoningEffort,
339
373
  } = options;
340
374
 
341
375
  const unsubs: Array<() => void> = [];
@@ -370,13 +404,85 @@ export function wireStreamEventMetrics(
370
404
  * claiming a revert that did not happen.
371
405
  */
372
406
  /**
373
- * The registry key the restore below is switching back to, held only across
374
- * that one setCurrentModel call. The MODEL_CHANGED listener reads it to keep
375
- * the routing chip quiet for this change: the chip's copy is "reason
376
- * unknown", which would be false here — the reason is known and the
377
- * `[Failover] Restored …` line states it.
407
+ * Registry keys THIS module switched to and narrated itself, with the time
408
+ * of the switch. The MODEL_CHANGED listener consumes an entry instead of
409
+ * emitting the generic routing chip, so neither half of a failover is
410
+ * narrated twice: the switch out is announced by `[Failover] from -> to
411
+ * (reason)` and the switch back by `[Failover] Restored …`, and a second
412
+ * line reading "(reason unknown)" for the same event would be both a
413
+ * duplicate and a lie — the reason is known in both cases.
414
+ *
415
+ * A timestamped map rather than a flag cleared around the setCurrentModel
416
+ * call, because MODEL_CHANGED does NOT arrive synchronously: the TUI reads
417
+ * it through the runtime event feed, which delivers after the emitting call
418
+ * has returned (observed live — a flag cleared in a finally block was always
419
+ * already null by the time the listener ran, and both failover halves got a
420
+ * duplicate chip). Entries expire on the same window the fallback-log
421
+ * correlation uses, so a switch whose event never arrives cannot silence an
422
+ * unrelated later change to the same model.
423
+ */
424
+ const selfNarratedSwitches = new Map<string, number>();
425
+
426
+ /**
427
+ * Run a registry switch this module narrates itself, with the chip suppressed
428
+ * for it. Returns the effort-remap sentence when the switch changed the level
429
+ * that goes on the wire, for the CALLER to place — see
430
+ * reconcileEffortWithServingModel for why it is not announced here.
431
+ */
432
+ const switchNarrated = (registryKey: string): string | undefined => {
433
+ selfNarratedSwitches.set(registryKey, Date.now());
434
+ try {
435
+ providerRegistry.setCurrentModel(registryKey);
436
+ } catch (err) {
437
+ selfNarratedSwitches.delete(registryKey); // no switch happened, nothing to suppress
438
+ throw err;
439
+ }
440
+ return reconcileEffortWithServingModel();
441
+ };
442
+
443
+ /**
444
+ * Re-resolve the REQUESTED reasoning level against whichever model is now
445
+ * serving, and hand back the sentence that says so when it had to change.
446
+ *
447
+ * Both halves of a failover come through switchNarrated, so this covers the
448
+ * switch out and the switch back. The requested level is left untouched in
449
+ * config — the fallback is temporary and the user's choice must survive it —
450
+ * but the level actually sent is the resolved one, and the SDK's own sentence
451
+ * explaining the remap is surfaced verbatim rather than reworded.
452
+ *
453
+ * The sentence is RETURNED rather than announced. On the failover-out path
454
+ * this function runs before retryTurn, and retryTurn rolls the conversation
455
+ * back to its pre-submission message count — which deleted this notice every
456
+ * time, exactly as it used to delete the failover notice itself. The caller
457
+ * folds it into the notice it hands to retryTurn so it survives the rollback;
458
+ * the restore path, which has no rollback after it, announces it directly.
378
459
  */
379
- let pendingRestoreKey: string | null = null;
460
+ const reconcileEffortWithServingModel = (): string | undefined => {
461
+ const serving = providerRegistry.getCurrentModel();
462
+ if (!serving.id) return undefined; // no model id on this surface: nothing to resolve against
463
+ const model = {
464
+ id: serving.id,
465
+ provider: serving.provider,
466
+ ...(serving.displayName ? { displayName: serving.displayName } : {}),
467
+ ...(serving.reasoningEffort ? { reasoningEffort: serving.reasoningEffort } : {}),
468
+ };
469
+ publishActiveEffortOptions(model);
470
+ // getConfiguredReasoningEffort reads config `provider.reasoningEffort` —
471
+ // the REQUESTED level. It must never be re-seeded from a previously snapped
472
+ // effective value, or a single failover onto a capped model would ratchet
473
+ // the level down for the rest of the session.
474
+ const requested = getConfiguredReasoningEffort?.();
475
+ if (requested === undefined || requested === '') return undefined;
476
+ return remapEffortForServingModel(requested, model).note;
477
+ };
478
+
479
+ /** True when this MODEL_CHANGED is one of our own narrated switches (consumes the record). */
480
+ const wasSelfNarrated = (registryKey: string): boolean => {
481
+ const narratedAt = selfNarratedSwitches.get(registryKey);
482
+ if (narratedAt === undefined) return false;
483
+ selfNarratedSwitches.delete(registryKey);
484
+ return Date.now() - narratedAt <= FALLBACK_CORRELATION_WINDOW_MS;
485
+ };
380
486
 
381
487
  const restoreConfiguredSelection = (): void => {
382
488
  const record = failoverState?.current();
@@ -386,12 +492,15 @@ export function wireStreamEventMetrics(
386
492
  return;
387
493
  }
388
494
  try {
389
- pendingRestoreKey = record.configuredRegistryKey;
390
- providerRegistry.setCurrentModel(record.configuredRegistryKey);
495
+ const effortNote = switchNarrated(record.configuredRegistryKey);
391
496
  failoverState.clear();
392
- announce(`[Failover] Restored ${record.configuredRegistryKey} for the next turn.`);
497
+ // Nothing rolls the transcript back after this point, so the restore line
498
+ // and any effort remap that came with it are announced directly.
499
+ announce(
500
+ `[Failover] Restored ${record.configuredRegistryKey} for the next turn.`
501
+ + (effortNote ? `\n[Failover] ${effortNote}` : ''),
502
+ );
393
503
  } catch (restoreErr) {
394
- pendingRestoreKey = null;
395
504
  logger.debug('failover restore failed', {
396
505
  configuredRegistryKey: record.configuredRegistryKey, error: String(restoreErr),
397
506
  });
@@ -466,11 +575,7 @@ export function wireStreamEventMetrics(
466
575
  // Degrade gracefully when a bare test double omits the providers feed.
467
576
  if (events.providers) {
468
577
  unsubs.push(events.providers.on('MODEL_CHANGED', (change) => {
469
- // Listeners run synchronously inside setCurrentModel, so this reads the
470
- // restore flag before anything else can touch it.
471
- const wasRestore = pendingRestoreKey !== null && change.registryKey === pendingRestoreKey;
472
- pendingRestoreKey = null;
473
- if (wasRestore) return;
578
+ if (wasSelfNarrated(change.registryKey)) return; // a [Failover] line already said this, with its reason
474
579
  queueMicrotask(() => {
475
580
  const chip = buildRoutingChip(change, providerOptimizer?.fallbackLog ?? [], Date.now());
476
581
  if (chip === null) return;
@@ -511,8 +616,12 @@ export function wireStreamEventMetrics(
511
616
  const errorClass = formatUserFacingErrorLine(errVal);
512
617
  // Capture FROM registry key before switching — needed for cost comparison.
513
618
  const fromRegistryKey = providerRegistry.getCurrentModel().registryKey;
619
+ // The effort remap that comes with the switch is carried, not announced:
620
+ // retryTurn's rollback below would delete it (see
621
+ // reconcileEffortWithServingModel).
622
+ let effortNote: string | undefined;
514
623
  try {
515
- providerRegistry.setCurrentModel(toRegistryKey);
624
+ effortNote = switchNarrated(toRegistryKey);
516
625
  } catch (switchErr) {
517
626
  // Switch failed — fall through to honest error display. This ends the
518
627
  // turn, so an EARLIER hop's switch (if this is a second failover
@@ -540,7 +649,19 @@ export function wireStreamEventMetrics(
540
649
  // Re-submit the last user turn on the new provider, handing the notice
541
650
  // to retryTurn so it outlives that call's transcript rollback (see the
542
651
  // retryTurn option doc). Emitting it here instead would delete it.
543
- retryTurn(`[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${billingSuffix}${costSuffix}`);
652
+ const failoverNotice = `[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${billingSuffix}${costSuffix}`
653
+ + (effortNote ? `\n[Failover] ${effortNote}` : '');
654
+ if (!retryTurn(failoverNotice)) {
655
+ // No turn to re-submit (the failed turn did not come from the
656
+ // composer, so there is no pre-submission snapshot to roll back to).
657
+ // The registry has still MOVED, so the switch gets narrated here and
658
+ // the original error surfaces — silence would leave the user on a
659
+ // different backend with no turn running and nothing said about it.
660
+ announce(failoverNotice);
661
+ systemMessageRouter.high(`[Error] ${errorClass}`);
662
+ restoreConfiguredSelection();
663
+ notifyErrorSurfaced(false);
664
+ }
544
665
  render();
545
666
  return;
546
667
  }
@@ -62,11 +62,14 @@ import {
62
62
  mkdirSync,
63
63
  openSync,
64
64
  readFileSync,
65
+ readdirSync,
65
66
  renameSync,
67
+ statSync,
66
68
  unlinkSync,
67
69
  } from 'node:fs';
68
70
  import { dirname, join } from 'node:path';
69
71
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
72
+ import { UNRECOGNIZED_SUFFIX } from '@/config/read-versioned.ts';
70
73
  import type { SessionSurface } from '@/runtime/index.ts';
71
74
 
72
75
  // ─── Constants ──────────────────────────────────────────────────────────────
@@ -320,7 +323,7 @@ class TranscriptJournalImpl implements TranscriptJournal {
320
323
  }
321
324
 
322
325
  private _ensureInitialised(): void {
323
- if (this._initialised && existsSync(this.path)) return;
326
+ if (this._initialised && journalHasContent(this.path)) return;
324
327
 
325
328
  mkdirSync(dirname(this.path), { recursive: true });
326
329
  const header: JournalHeader = {
@@ -328,11 +331,20 @@ class TranscriptJournalImpl implements TranscriptJournal {
328
331
  sessionId: this._sessionId,
329
332
  createdAt: Date.now(),
330
333
  };
331
- // Append the header as the first line. If the file already exists (e.g.
332
- // process restarted mid-session), we start appending records after
333
- // whatever is already there — the replay function handles seq ordering.
334
- // However, to keep things clean, if the file doesn't exist we write fresh.
335
- if (!existsSync(this.path)) {
334
+ // Append the header as the first line. If the file already exists with
335
+ // content (e.g. process restarted mid-session), we start appending records
336
+ // after whatever is already there — the replay function handles seq
337
+ // ordering.
338
+ //
339
+ // The guard is `journalHasContent`, not `existsSync`: a crash between
340
+ // creating the file and writing the header leaves a zero-byte journal, and
341
+ // an existence-only check would then skip the header forever. Every record
342
+ // appended after that would sit in a header-less file, so the next replay
343
+ // would read record 0 where the header belongs, fail the version gate, and
344
+ // quarantine the entire journal — losing exactly the turns this module
345
+ // exists to preserve. Deciding on content instead means a zero-byte
346
+ // journal is re-headered and stays replayable.
347
+ if (!journalHasContent(this.path)) {
336
348
  appendFileSync(this.path, JSON.stringify(header) + '\n', { mode: 0o600 });
337
349
  }
338
350
  this._initialised = true;
@@ -358,8 +370,134 @@ function isValidRecord(value: unknown): value is JournalRecord {
358
370
 
359
371
  function quarantineJournal(journalPath: string): void {
360
372
  try {
361
- renameSync(journalPath, `${journalPath}.unrecognized`);
373
+ renameSync(journalPath, `${journalPath}${UNRECOGNIZED_SUFFIX}`);
362
374
  } catch {
363
375
  // Best-effort — if rename fails, proceed silently.
364
376
  }
365
377
  }
378
+
379
+ /** True when the journal file exists AND holds at least one byte. */
380
+ function journalHasContent(journalPath: string): boolean {
381
+ try {
382
+ return statSync(journalPath).size > 0;
383
+ } catch {
384
+ return false;
385
+ }
386
+ }
387
+
388
+ // ─── Reaping orphaned journals ──────────────────────────────────────────────
389
+ //
390
+ // `rotate()` deletes a journal once its records have been folded into a
391
+ // snapshot or replayed. A session that crashes and is never resumed never
392
+ // reaches either, so its journal stays on disk forever — one file per
393
+ // abandoned session. The SDK's registered append-only retention sweep does not
394
+ // cover this home-scoped path, so the reclaim happens here.
395
+
396
+ const JOURNAL_PREFIX = 'transcript-';
397
+ const JOURNAL_SUFFIX = '.journal';
398
+
399
+ /** How long an untouched journal for a non-live session is kept: 7 days. */
400
+ export const JOURNAL_ORPHAN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
401
+
402
+ /**
403
+ * Hard ceiling on reapable journals kept after the age rule, newest kept.
404
+ * A burst of crashes inside the age window is still bounded.
405
+ */
406
+ export const JOURNAL_ORPHAN_MAX_FILES = 50;
407
+
408
+ export interface JournalReapResult {
409
+ /** Journal files examined this sweep. */
410
+ readonly scanned: number;
411
+ /** Journal files deleted this sweep. */
412
+ readonly reaped: number;
413
+ }
414
+
415
+ export interface JournalReapOptions {
416
+ /**
417
+ * Is this session open in a still-running process? Injected rather than
418
+ * imported so this module keeps no dependency on the liveness marker — the
419
+ * composition point (runtime/durability-housekeeping.ts) supplies the real
420
+ * check, and tests supply their own.
421
+ */
422
+ readonly isSessionLive: (sessionId: string) => boolean;
423
+ /** The session this process is writing right now; never reaped. */
424
+ readonly currentSessionId?: string | null;
425
+ readonly now?: () => number;
426
+ /** Override the age window (tests). */
427
+ readonly maxAgeMs?: number;
428
+ /** Override the count cap (tests). */
429
+ readonly maxFiles?: number;
430
+ }
431
+
432
+ /**
433
+ * Delete transcript journals belonging to sessions that crashed and were never
434
+ * resumed.
435
+ *
436
+ * A journal is reapable only when it is neither the current session's nor
437
+ * apparently open in another running process. Of those, one is deleted when it
438
+ * is empty (a zero-byte file holds no records, so nothing can be lost) or
439
+ * untouched for longer than the age window; whatever survives both rules is
440
+ * then capped by count, newest kept.
441
+ *
442
+ * The rules are deliberately mtime- and liveness-based, never parse-based: an
443
+ * unparseable TAIL is the normal, expected shape of a journal killed
444
+ * mid-append and is exactly the data replay is there to salvage, so a parse
445
+ * failure must never make a journal reapable.
446
+ *
447
+ * Idempotent and concurrency-safe: a journal another sweeper unlinked between
448
+ * the listing and this unlink (ENOENT) counts as reaped, not as an error.
449
+ */
450
+ export function reapOrphanedJournals(surface: SessionSurface, options: JournalReapOptions): JournalReapResult {
451
+ const dir = join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot);
452
+ let names: string[];
453
+ try {
454
+ names = readdirSync(dir).filter((n) => n.startsWith(JOURNAL_PREFIX) && n.endsWith(JOURNAL_SUFFIX));
455
+ } catch {
456
+ return { scanned: 0, reaped: 0 };
457
+ }
458
+
459
+ const now = options.now?.() ?? Date.now();
460
+ const maxAgeMs = options.maxAgeMs ?? JOURNAL_ORPHAN_MAX_AGE_MS;
461
+ const maxFiles = options.maxFiles ?? JOURNAL_ORPHAN_MAX_FILES;
462
+
463
+ let reaped = 0;
464
+ const survivors: { readonly path: string; readonly mtimeMs: number }[] = [];
465
+
466
+ for (const name of names) {
467
+ const sessionId = name.slice(JOURNAL_PREFIX.length, name.length - JOURNAL_SUFFIX.length);
468
+ if (sessionId.length === 0) continue;
469
+ if (options.currentSessionId && sessionId === options.currentSessionId) continue;
470
+ if (options.isSessionLive(sessionId)) continue;
471
+
472
+ const path = join(dir, name);
473
+ let stats: { size: number; mtimeMs: number };
474
+ try {
475
+ stats = statSync(path);
476
+ } catch {
477
+ continue; // vanished under us
478
+ }
479
+ if (stats.size === 0 || now - stats.mtimeMs > maxAgeMs) {
480
+ if (unlinkJournalIfPresent(path)) reaped++;
481
+ continue;
482
+ }
483
+ survivors.push({ path, mtimeMs: stats.mtimeMs });
484
+ }
485
+
486
+ if (survivors.length > maxFiles) {
487
+ survivors.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
488
+ for (const victim of survivors.slice(0, survivors.length - maxFiles)) {
489
+ if (unlinkJournalIfPresent(victim.path)) reaped++;
490
+ }
491
+ }
492
+
493
+ return { scanned: names.length, reaped };
494
+ }
495
+
496
+ function unlinkJournalIfPresent(path: string): boolean {
497
+ try {
498
+ unlinkSync(path);
499
+ return true;
500
+ } catch (err) {
501
+ return (err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT';
502
+ }
503
+ }