@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
@@ -1,8 +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
+ import { classifyProviderSetup } from '../providers/provider-classification.ts';
6
+ import type { FailoverTurnState } from './active-model-identity.ts';
5
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';
6
13
 
7
14
  /**
8
15
  * Live stream and tool-execution metrics maintained by wireStreamEventMetrics.
@@ -73,7 +80,20 @@ interface StreamOrchestrator {
73
80
 
74
81
  /** Minimal provider surface required for the stream stall watchdog and failover switching. */
75
82
  interface StreamProviderRegistry {
76
- 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
+ };
77
97
  setCurrentModel(registryKey: string): void;
78
98
  }
79
99
 
@@ -101,6 +121,14 @@ interface FailoverOptimizer {
101
121
  interface StreamSystemMessageRouter {
102
122
  high(message: string): void;
103
123
  low(message: string): void;
124
+ /**
125
+ * Unconditional conversation delivery (see system-message-router.ts). Used
126
+ * for provider-switch notices: which backend serves a turn — and therefore
127
+ * who bills for it — is never ambient chatter that a routing preference or
128
+ * the noise gate may filter out. Optional so bare test doubles that supply
129
+ * only high/low still type-check; announce() below falls back to high().
130
+ */
131
+ userReceipt?(message: string): void;
104
132
  }
105
133
 
106
134
  /**
@@ -166,8 +194,21 @@ export interface WireStreamEventMetricsOptions {
166
194
  * Callback the caller provides to re-submit the last user turn on a
167
195
  * different provider after a successful failover switch. Called only when
168
196
  * the optimizer is enabled and a viable next provider exists in the chain.
197
+ *
198
+ * The re-submission rolls the conversation back to its pre-submission
199
+ * message count, which deletes everything the failed turn added — including
200
+ * a failover notice appended before the call. So the notice is passed IN
201
+ * rather than emitted before: the caller re-posts it after its rollback, and
202
+ * it survives to be read. Implementations that do not roll back may ignore
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.
169
210
  */
170
- readonly retryTurn?: () => void;
211
+ readonly retryTurn?: (notice?: string) => boolean;
171
212
  /**
172
213
  * Optional cost catalog for attaching per-1M-token cost information to
173
214
  * the failover notice. When provided and both models have non-zero pricing,
@@ -189,6 +230,31 @@ export interface WireStreamEventMetricsOptions {
189
230
  * callers omit it.
190
231
  */
191
232
  readonly stallThresholdMs?: number | undefined;
233
+ /**
234
+ * Shared holder for the live failover record (core/active-model-identity.ts).
235
+ * Set when failover switches the registry off the user's configured
236
+ * selection, cleared once serving is restored to it. The render frame reads
237
+ * the same object, so the header and footer describe the switch while it is
238
+ * in effect. Omitted by unit tests that only exercise notice content.
239
+ */
240
+ readonly failoverState?: FailoverTurnState;
241
+ /**
242
+ * The user's configured model selection (config `provider.model`) as a
243
+ * registry key. Read at the moment of a failover switch so the turn-end
244
+ * restore targets what the user actually chose — not whatever the registry
245
+ * happened to hold. Without it, failover still works but no restore or
246
+ * divergence marker is possible, so both are skipped rather than guessed at.
247
+ */
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;
192
258
  }
193
259
 
194
260
  /** Result of wireStreamEventMetrics. */
@@ -196,10 +262,12 @@ export interface WireStreamEventMetricsResult {
196
262
  /** Unsubscribe functions; push into the parent unsubs array for cleanup on exit. */
197
263
  readonly unsubs: ReadonlyArray<() => void>;
198
264
  /**
199
- * Clear the per-turn failover visited-provider set.
200
- * Call this on every new user submission so the visited set does not bleed
201
- * across independent turns (the set is also cleared automatically on
202
- * TURN_COMPLETED, but a new submission may arrive before TURN_COMPLETED fires).
265
+ * Clear the per-turn failover visited-provider set AND restore the user's
266
+ * configured model selection if a failover left serving somewhere else.
267
+ * Call this on every new user submission so neither the visited set nor a
268
+ * per-turn provider switch bleeds across independent turns (both are also
269
+ * handled on TURN_COMPLETED/TURN_CANCEL, but a new submission may arrive
270
+ * before either fires).
203
271
  */
204
272
  readonly clearFailoverVisited: () => void;
205
273
  /**
@@ -253,6 +321,33 @@ function buildCostDeltaSuffix(
253
321
  return ` [cost/1M: input ${fmt(fromCost.input)}→${fmt(toCost.input)}, output ${fmt(fromCost.output)}→${fmt(toCost.output)}]`;
254
322
  }
255
323
 
324
+ /**
325
+ * Build the billing-class segment of a failover notice, e.g.
326
+ * ` [billing: API key → Subscription — billing class changed]`.
327
+ *
328
+ * This path is NOT the synthetic provider's tier-isolated failover, which is
329
+ * where the documented "free, paid and subscription tiers never mix" contract
330
+ * lives (docs/providers-and-routing.md:77-128, enforced in the SDK's
331
+ * synthetic.ts by CanonicalModel.tier). The optimizer chain consumed here
332
+ * carries no tier metadata at all — its nodes are
333
+ * `{ position, providerId, modelId, capable, explanation }` and `explanation`
334
+ * describes functional capability (streaming, tool calling, context size),
335
+ * never billing. So the switch cannot be constrained by a tier it cannot see;
336
+ * what it CAN do is say out loud which billing class it moved to, so a user
337
+ * who does not want their subscription spent on an automatic retry can object
338
+ * and turn the optimizer off.
339
+ *
340
+ * Classification comes from providers/provider-classification.ts, which is
341
+ * honest about ignorance: an unrecognised provider id reports "Unknown" rather
342
+ * than being quietly assumed safe.
343
+ */
344
+ function buildBillingSuffix(fromProviderId: string, toProviderId: string): string {
345
+ const from = classifyProviderSetup({ providerId: fromProviderId }).setupLabel;
346
+ const to = classifyProviderSetup({ providerId: toProviderId }).setupLabel;
347
+ const changed = from !== to ? ' — billing class changed' : '';
348
+ return ` [billing: ${from} → ${to}${changed}]`;
349
+ }
350
+
256
351
  /**
257
352
  * Wire STREAM_* and TOOL_* runtime events to the provided StreamMetrics object
258
353
  * and install the stream-stall watchdog. The caller owns the metrics object
@@ -273,11 +368,149 @@ export function wireStreamEventMetrics(
273
368
  const {
274
369
  events, metrics, orchestrator, providerRegistry,
275
370
  systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
276
- isApprovalPending, stallThresholdMs,
371
+ isApprovalPending, stallThresholdMs, failoverState, getConfiguredRegistryKey,
372
+ getConfiguredReasoningEffort,
277
373
  } = options;
278
374
 
279
375
  const unsubs: Array<() => void> = [];
280
376
 
377
+ /**
378
+ * Deliver a provider-switch notice to the conversation unconditionally.
379
+ * Prefers the router's userReceipt channel, which skips the noise gate and
380
+ * the ui.operationalMessages routing target entirely, so the line cannot be
381
+ * filtered away by a preference and is treated as real content even while
382
+ * the splash still owns the screen. Falls back to high() for bare doubles.
383
+ */
384
+ const announce = (message: string): void => {
385
+ if (systemMessageRouter.userReceipt) systemMessageRouter.userReceipt(message);
386
+ else systemMessageRouter.high(message);
387
+ };
388
+
389
+ /**
390
+ * Put serving back on the user's configured selection after a failed-over
391
+ * turn ends.
392
+ *
393
+ * Failover is per-turn recovery, not a permanent re-selection. Without this,
394
+ * the one setCurrentModel() call that rescued a single failed turn stayed in
395
+ * force for the rest of the session: every later turn ran on the fallback
396
+ * backend while nothing in the session state or the footer had changed to
397
+ * say so. The user's configured selection is authoritative, so the NEXT turn
398
+ * starts from it again. If that backend is still unhealthy, the normal
399
+ * per-turn failover handles it again — visibly, with a fresh notice — which
400
+ * is exactly the behaviour a sticky override was hiding.
401
+ *
402
+ * The record is kept (not cleared) when the restore itself fails, so the
403
+ * header and footer keep reporting the real serving backend rather than
404
+ * claiming a revert that did not happen.
405
+ */
406
+ /**
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.
459
+ */
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
+ };
486
+
487
+ const restoreConfiguredSelection = (): void => {
488
+ const record = failoverState?.current();
489
+ if (!record || !failoverState) return;
490
+ if (providerRegistry.getCurrentModel().registryKey === record.configuredRegistryKey) {
491
+ failoverState.clear();
492
+ return;
493
+ }
494
+ try {
495
+ const effortNote = switchNarrated(record.configuredRegistryKey);
496
+ failoverState.clear();
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
+ );
503
+ } catch (restoreErr) {
504
+ logger.debug('failover restore failed', {
505
+ configuredRegistryKey: record.configuredRegistryKey, error: String(restoreErr),
506
+ });
507
+ announce(
508
+ `[Failover] Could not switch back to ${record.configuredRegistryKey}; still serving ${record.servingRegistryKey}.`,
509
+ );
510
+ }
511
+ render();
512
+ };
513
+
281
514
  unsubs.push(events.turns.on('STREAM_START', () => {
282
515
  metrics.startTime = Date.now();
283
516
  metrics.deltaCount = 0;
@@ -318,8 +551,20 @@ export function wireStreamEventMetrics(
318
551
  // (caller clears via clearFailoverVisited(), wired in main.ts).
319
552
  const failoverVisited = new Set<string>();
320
553
 
554
+ // Both terminal turn outcomes end the failover's authority over the
555
+ // registry: a completed turn got its answer, a cancelled one will not.
556
+ // (TURN_COMPLETED and TURN_ERROR are mutually exclusive per turn in the SDK
557
+ // — TURN_COMPLETED is emitted only on the success path in
558
+ // orchestrator-turn-helpers, TURN_ERROR only from the orchestrator's catch —
559
+ // so restoring here can never undo a switch whose retry has not run yet.)
321
560
  unsubs.push(events.turns.on('TURN_COMPLETED', () => {
322
561
  failoverVisited.clear();
562
+ restoreConfiguredSelection();
563
+ }));
564
+
565
+ unsubs.push(events.turns.on('TURN_CANCEL', () => {
566
+ failoverVisited.clear();
567
+ restoreConfiguredSelection();
323
568
  }));
324
569
 
325
570
  // Routing chip (never-silent model change): every MODEL_CHANGED that is not
@@ -330,6 +575,7 @@ export function wireStreamEventMetrics(
330
575
  // Degrade gracefully when a bare test double omits the providers feed.
331
576
  if (events.providers) {
332
577
  unsubs.push(events.providers.on('MODEL_CHANGED', (change) => {
578
+ if (wasSelfNarrated(change.registryKey)) return; // a [Failover] line already said this, with its reason
333
579
  queueMicrotask(() => {
334
580
  const chip = buildRoutingChip(change, providerOptimizer?.fallbackLog ?? [], Date.now());
335
581
  if (chip === null) return;
@@ -370,33 +616,64 @@ export function wireStreamEventMetrics(
370
616
  const errorClass = formatUserFacingErrorLine(errVal);
371
617
  // Capture FROM registry key before switching — needed for cost comparison.
372
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;
373
623
  try {
374
- providerRegistry.setCurrentModel(toRegistryKey);
624
+ effortNote = switchNarrated(toRegistryKey);
375
625
  } catch (switchErr) {
376
- // Switch failed — fall through to honest error display.
626
+ // Switch failed — fall through to honest error display. This ends the
627
+ // turn, so an EARLIER hop's switch (if this is a second failover
628
+ // within the same turn) loses its authority here just as it would on
629
+ // any other terminal outcome.
377
630
  logger.debug('failover setCurrentModel failed', { toRegistryKey, error: String(switchErr) });
378
631
  systemMessageRouter.high(`[Error] ${errorClass}`);
632
+ restoreConfiguredSelection();
379
633
  render();
380
634
  return;
381
635
  }
382
636
  // Record the selected provider as visited before the retry fires so
383
637
  // a subsequent TURN_ERROR from that provider also skips it.
384
638
  failoverVisited.add(next.providerId);
639
+ // Remember the user's configured selection so the turn-end restore
640
+ // targets it, and so both shell surfaces can name it while the switch
641
+ // is in force. Sticky across a second hop within the same turn.
642
+ const configuredRegistryKey = getConfiguredRegistryKey?.();
643
+ if (configuredRegistryKey) {
644
+ failoverState?.begin({ configuredRegistryKey, servingRegistryKey: toRegistryKey });
645
+ }
385
646
  providerOptimizer.recordFallbackTransition(fromProvider, next.providerId, errorClass);
386
647
  const costSuffix = buildCostDeltaSuffix(costLookup, fromRegistryKey, toRegistryKey);
387
- systemMessageRouter.high(
388
- `[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${costSuffix}`,
389
- );
648
+ const billingSuffix = buildBillingSuffix(fromProvider, next.providerId);
649
+ // Re-submit the last user turn on the new provider, handing the notice
650
+ // to retryTurn so it outlives that call's transcript rollback (see the
651
+ // retryTurn option doc). Emitting it here instead would delete it.
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
+ }
390
665
  render();
391
- // Re-submit the last user turn on the new provider.
392
- retryTurn();
393
666
  return;
394
667
  }
395
668
 
396
669
  // Chain exhausted — all capable candidates have been visited or none exist.
397
- systemMessageRouter.high(
670
+ // The turn is over, so any switch made earlier in it loses its authority:
671
+ // restore the configured selection before surfacing the error, or the
672
+ // user's next turn would silently start on the last fallback tried.
673
+ announce(
398
674
  `[Failover] Chain exhausted — no alternative provider available. Original error: ${formatUserFacingErrorLine(errVal)}`,
399
675
  );
676
+ restoreConfiguredSelection();
400
677
  notifyErrorSurfaced(true);
401
678
  render();
402
679
  return;
@@ -494,7 +771,11 @@ export function wireStreamEventMetrics(
494
771
  function notifyErrorSurfaced(exhausted: boolean) { _errorSurfacedCb?.(exhausted); }
495
772
  return {
496
773
  unsubs,
497
- clearFailoverVisited: () => failoverVisited.clear(),
774
+ // A new user submission is also a turn boundary: restore here too, so a
775
+ // turn that ended without any terminal event (an aborted stream that
776
+ // emitted neither TURN_COMPLETED nor TURN_CANCEL) still cannot leave the
777
+ // next turn silently pinned to a fallback backend.
778
+ clearFailoverVisited: () => { failoverVisited.clear(); restoreConfiguredSelection(); },
498
779
  onErrorSurfaced: (cb: (exhausted: boolean) => void) => { _errorSurfacedCb = cb; },
499
780
  };
500
781
  }
@@ -224,6 +224,28 @@ export class SystemMessageRouter {
224
224
  wrfc(message: string, priority: SystemMessagePriority = 'high'): void {
225
225
  this.routeTypedSystemMessage(message, priority, 'wrfc');
226
226
  }
227
+
228
+ /**
229
+ * Route a message that must reach the conversation unconditionally. Skips
230
+ * the noise gate and the configured routing target, and marks the message
231
+ * `isUserReceipt` so the splash check in
232
+ * ConversationManager.rebuildHistory() treats it as real visible content —
233
+ * otherwise it would be invisible while the splash still owns the screen.
234
+ *
235
+ * Two callers qualify today:
236
+ * - a receipt for an explicit user action at this shell (answering the
237
+ * startup recovery modal's Resume/Keep/Remove choice): the answer to a
238
+ * question the user was just asked, never ambient chatter;
239
+ * - an automatic provider failover switch (stream-event-wiring.ts):
240
+ * which backend serves a turn decides who bills for it, so it must not
241
+ * be filterable by a routing preference. `[Failover]` is also in
242
+ * FORCE_CONVERSATION_PREFIXES, but that list is a per-prefix opt-in
243
+ * applied AFTER the noise gate; this channel is the structural
244
+ * guarantee rather than a second string match.
245
+ */
246
+ userReceipt(message: string): void {
247
+ this.conversation.addTypedSystemMessage(message, classifySystemMessageKind(message), { isUserReceipt: true });
248
+ }
227
249
  }
228
250
 
229
251
  // ---------------------------------------------------------------------------
@@ -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
+ }