@pellux/goodvibes-tui 1.19.6 → 1.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -1613
  4. package/docs/README.md +4 -4
  5. package/docs/foundation-artifacts/operator-contract.json +1 -1
  6. package/package.json +7 -7
  7. package/src/cli/bundle-command.ts +1 -1
  8. package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
  9. package/src/cli/entrypoint.ts +5 -1
  10. package/src/cli/launch-auto-update.ts +63 -4
  11. package/src/cli/management.ts +1 -1
  12. package/src/cli/tui-startup.ts +131 -15
  13. package/src/config/surface.ts +21 -0
  14. package/src/config/tui-extension-settings.ts +9 -1
  15. package/src/core/active-model-identity.ts +153 -0
  16. package/src/core/conversation-line-cache.ts +1 -1
  17. package/src/core/conversation-rendering.ts +70 -24
  18. package/src/core/conversation-tool-groups.ts +23 -10
  19. package/src/core/conversation-types.ts +9 -0
  20. package/src/core/conversation-user-receipts.ts +42 -0
  21. package/src/core/conversation.ts +20 -17
  22. package/src/core/rewind-turn-anchors.ts +12 -13
  23. package/src/core/session-recovery.ts +59 -2
  24. package/src/core/session-resume-core.ts +206 -0
  25. package/src/core/stream-event-wiring.ts +174 -14
  26. package/src/core/system-message-router.ts +22 -0
  27. package/src/core/transcript-journal.ts +32 -6
  28. package/src/core/turn-event-wiring.ts +55 -12
  29. package/src/input/command-args-hint.ts +11 -2
  30. package/src/input/command-registry.ts +19 -0
  31. package/src/input/commands/checkpoint-runtime.ts +3 -3
  32. package/src/input/commands/health-runtime.ts +1 -3
  33. package/src/input/commands/local-runtime.ts +1 -1
  34. package/src/input/commands/rewind-runtime.ts +132 -1
  35. package/src/input/commands/runtime-services.ts +11 -1
  36. package/src/input/commands/secret-runtime.ts +1 -1
  37. package/src/input/commands/session-content.ts +9 -1
  38. package/src/input/commands/session-picker-filter.ts +28 -0
  39. package/src/input/commands/session-workflow.ts +62 -73
  40. package/src/input/commands/session.ts +36 -1
  41. package/src/input/commands/shell-core.ts +57 -5
  42. package/src/input/commands/update-runtime.ts +120 -4
  43. package/src/input/commands/websearch-runtime.ts +1 -1
  44. package/src/input/handler-content-actions.ts +63 -39
  45. package/src/input/handler-feed-routes.ts +17 -3
  46. package/src/input/handler-feed.ts +3 -0
  47. package/src/input/handler-interactions.ts +19 -23
  48. package/src/input/handler-modal-token-routes.ts +5 -0
  49. package/src/input/handler-types.ts +4 -1
  50. package/src/input/handler-ui-state.ts +6 -2
  51. package/src/input/handler.ts +19 -9
  52. package/src/input/search.ts +94 -2
  53. package/src/input/selection-modal-queue.ts +176 -0
  54. package/src/main.ts +66 -61
  55. package/src/panels/builtin/operations.ts +2 -1
  56. package/src/panels/provider-health-domains.ts +2 -2
  57. package/src/{cli → providers}/provider-classification.ts +12 -0
  58. package/src/renderer/block-actions-overlay.ts +59 -0
  59. package/src/renderer/block-actions.ts +1 -3
  60. package/src/renderer/context-status-hint.ts +73 -5
  61. package/src/renderer/conversation-layout.ts +16 -0
  62. package/src/renderer/conversation-overlays.ts +6 -0
  63. package/src/renderer/header-line.ts +146 -0
  64. package/src/renderer/help-overlay.ts +69 -53
  65. package/src/renderer/selection-modal-overlay.ts +66 -10
  66. package/src/renderer/shell-surface.ts +51 -0
  67. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  68. package/src/renderer/ui-factory.ts +26 -79
  69. package/src/runtime/bootstrap-command-context.ts +5 -1
  70. package/src/runtime/bootstrap-command-parts.ts +5 -2
  71. package/src/runtime/bootstrap-core.ts +3 -3
  72. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  73. package/src/runtime/bootstrap-shell.ts +31 -16
  74. package/src/runtime/bootstrap.ts +13 -18
  75. package/src/runtime/code-index-services.ts +19 -0
  76. package/src/runtime/durability-services.ts +11 -7
  77. package/src/runtime/index.ts +12 -0
  78. package/src/runtime/process-lifecycle.ts +63 -2
  79. package/src/runtime/recovery-autosave.ts +48 -0
  80. package/src/runtime/recovery-decisions.ts +126 -0
  81. package/src/runtime/recovery-offer-wiring.ts +89 -0
  82. package/src/runtime/recovery-prompt.ts +362 -0
  83. package/src/runtime/resume-notice.ts +28 -93
  84. package/src/runtime/services.ts +14 -16
  85. package/src/runtime/session-liveness-marker.ts +120 -0
  86. package/src/runtime/session-pointer-surface.ts +22 -0
  87. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  88. package/src/runtime/session-storage-services.ts +41 -0
  89. package/src/runtime/workspace-checkpointing.ts +10 -2
  90. package/src/shell/recovery-input-helpers.ts +7 -3
  91. package/src/shell/retry-affordance.ts +123 -0
  92. package/src/shell/ui-openers.ts +9 -0
  93. package/src/utils/splash-lines.ts +1 -1
  94. package/src/version.ts +1 -1
@@ -0,0 +1,206 @@
1
+ /**
2
+ * session-resume-core.ts — the ONE resume routine both resume seams call.
3
+ *
4
+ * Two independent call sites used to duplicate (and diverge from) this
5
+ * sequence: `/session resume` (session-workflow.ts) and the panel/session-
6
+ * browser resume (bootstrap-hook-bridge.ts's `createResumeSessionHandler`).
7
+ * Divergences found by audit:
8
+ * - the panel seam skipped `restoreTurnAnchors` (message-anchored /rewind
9
+ * silently had no anchors after a panel resume)
10
+ * - the panel seam skipped `conversation.resetAll()` before `fromJSON()`
11
+ * - the panel seam skipped the `selectModel` reselection fallback (it set
12
+ * `runtime.model` straight from the saved meta, never re-resolving
13
+ * through the live provider registry)
14
+ * - the panel seam duplicated the panel-reopen loop WITHOUT the
15
+ * modal-redirect skip, so a MIGRATE-TO-MODAL id could pop a modal
16
+ * mid-resume
17
+ *
18
+ * Both seams now call this module so those four behaviors (plus the
19
+ * panel-reopen-cap honesty note) are guaranteed identical by construction,
20
+ * not by copy-paste discipline between two files.
21
+ *
22
+ * Callers own everything ABOVE and AROUND this core sequence: printing or
23
+ * logging the outcome in their own idiom (`ctx.print` vs `conversation.log`),
24
+ * and any extra plumbing that only one seam performs (hookDispatcher.fire,
25
+ * sessionSpine.reopen, sharedSessionBroker.reopenSession,
26
+ * writeLastSessionPointer — all bootstrap-hook-bridge-only, pre-existing
27
+ * asymmetries out of this module's scope).
28
+ */
29
+ import type { SessionManager, SessionMeta } from '@pellux/goodvibes-sdk/platform/sessions';
30
+ import type { SessionSurface } from '@/runtime/index.ts';
31
+ import type { ConversationManager } from './conversation.ts';
32
+ import { restoreTurnAnchors } from './rewind-turn-anchors.ts';
33
+ import { replayJournalForSession, type ReplayIntoConversationResult } from './session-recovery.ts';
34
+
35
+ export interface SessionResumeRuntime {
36
+ sessionId: string;
37
+ model: string;
38
+ provider: string;
39
+ }
40
+
41
+ export interface SessionResumePanelManager {
42
+ open(id: string): unknown;
43
+ show(): void;
44
+ getModalRedirect(id: string): string | undefined;
45
+ }
46
+
47
+ export interface SessionResumeDeps {
48
+ readonly sessionManager: Pick<SessionManager, 'load' | 'save'>;
49
+ readonly conversation: ConversationManager;
50
+ readonly runtime: SessionResumeRuntime;
51
+ /**
52
+ * The app's declare-once session-storage handle. Both resume seams pass the
53
+ * SAME one the runtime writes through, so the anchor sidecar and the
54
+ * transcript journal this routine reads are guaranteed to be the files the
55
+ * live session actually wrote — the scope can no longer be re-guessed per
56
+ * seam.
57
+ */
58
+ readonly surface: SessionSurface;
59
+ readonly panelManager: SessionResumePanelManager;
60
+ /**
61
+ * Reselects the saved model through the live provider registry, falling
62
+ * back to the raw saved id on failure (a saved model may no longer exist
63
+ * locally). Omit to use the saved id directly without reselection.
64
+ */
65
+ readonly selectModel?: (model: string) => Promise<{ readonly registryKey: string; readonly providerId: string }>;
66
+ readonly hydrateSessionUsage?: () => void;
67
+ /**
68
+ * Deliberate cap on how many saved panels are reopened at once — resuming
69
+ * into a workspace crowded with every panel that happened to be open is
70
+ * its own kind of surprise. Overflow beyond the cap is reported in the
71
+ * outcome (`panels.notReopened`), never silently dropped. Defaults to 4.
72
+ */
73
+ readonly panelReopenLimit?: number;
74
+ }
75
+
76
+ export interface PanelReopenOutcome {
77
+ readonly reopened: readonly string[];
78
+ readonly movedToModal: readonly string[];
79
+ /** Saved panel ids beyond `panelReopenLimit` — not attempted, honestly reported (see /panels to open the rest). */
80
+ readonly notReopened: readonly string[];
81
+ }
82
+
83
+ export interface SessionResumeOutcome {
84
+ readonly meta: SessionMeta;
85
+ readonly resumedMessageCount: number;
86
+ readonly restoredAnchorCount: number;
87
+ readonly journalReplay: ReplayIntoConversationResult;
88
+ readonly panels: PanelReopenOutcome;
89
+ }
90
+
91
+ /**
92
+ * Exported so both session-workflow.ts's standalone `reopenPanelsFromReturnContext`
93
+ * (kept for its existing direct unit-test coverage — see
94
+ * session-workflow-panel-restore.test.ts) and `resumeSessionCore` below share
95
+ * the exact same default cap.
96
+ */
97
+ export const DEFAULT_PANEL_REOPEN_LIMIT = 4;
98
+
99
+ export function reopenPanelsWithModalSkip(
100
+ panelManager: SessionResumePanelManager,
101
+ openPanelIds: readonly string[] | undefined,
102
+ limit: number,
103
+ ): PanelReopenOutcome {
104
+ if (!openPanelIds || openPanelIds.length === 0) return { reopened: [], movedToModal: [], notReopened: [] };
105
+ const within = openPanelIds.slice(0, limit);
106
+ const overflow = openPanelIds.slice(limit);
107
+ const reopened: string[] = [];
108
+ const movedToModal: string[] = [];
109
+ for (const panelId of within) {
110
+ // A MIGRATE-TO-MODAL id has no panel to restore — a modal is not part of
111
+ // the saved panel layout. Skip it (don't pop a modal mid-resume) and note
112
+ // it once, rather than firing open() and revealing an empty workspace.
113
+ if (panelManager.getModalRedirect(panelId) !== undefined) {
114
+ movedToModal.push(panelId);
115
+ continue;
116
+ }
117
+ try {
118
+ panelManager.open(panelId);
119
+ reopened.push(panelId);
120
+ } catch {
121
+ // Ignore unknown or currently unavailable panel ids during resume.
122
+ }
123
+ }
124
+ if (reopened.length > 0) panelManager.show();
125
+ return { reopened, movedToModal, notReopened: overflow };
126
+ }
127
+
128
+ /**
129
+ * The canonical resume sequence: load, reset + restore the conversation,
130
+ * restore rewind anchors, replay any post-snapshot journal records, hydrate
131
+ * footer usage, reselect the model, and reopen saved panels (modal-redirect
132
+ * aware, cap honestly reported).
133
+ */
134
+ export async function resumeSessionCore(sessionId: string, deps: SessionResumeDeps): Promise<SessionResumeOutcome> {
135
+ const { meta, messages } = deps.sessionManager.load(sessionId);
136
+
137
+ deps.conversation.resetAll();
138
+ deps.conversation.fromJSON({
139
+ messages: messages as never[],
140
+ title: meta.title,
141
+ titleSource: meta.titleSource,
142
+ });
143
+ deps.conversation.rebuildHistory();
144
+ deps.runtime.sessionId = sessionId;
145
+
146
+ // Restore this session's message-anchored rewind anchors from its sidecar
147
+ // so /rewind works identically before and after the resume — the in-memory
148
+ // registry is process-local, so a fresh process starts with none for the
149
+ // loaded session.
150
+ const restoredAnchorCount = restoreTurnAnchors(sessionId, deps.surface);
151
+
152
+ // Journal replay: recover turns that post-date the loaded snapshot.
153
+ const journalReplay = replayJournalForSession({
154
+ surface: deps.surface,
155
+ sessionId,
156
+ snapshotTimestamp: meta.timestamp ?? 0,
157
+ conversation: deps.conversation,
158
+ persistSnapshot: (replayedMessages) => {
159
+ deps.sessionManager.save(sessionId, replayedMessages as never[], {
160
+ title: deps.conversation.title || meta.title,
161
+ model: meta.model,
162
+ provider: meta.provider,
163
+ timestamp: Date.now(),
164
+ titleSource: meta.titleSource,
165
+ returnContext: meta.returnContext,
166
+ // Machinery: this write exists only to durably close the journal gap
167
+ // the replay just filled. The user's own save/fork/rename acts stamp
168
+ // 'user' at their own call sites (session-workflow.ts).
169
+ saveSource: 'auto',
170
+ });
171
+ },
172
+ });
173
+
174
+ // Hydrate the footer's token counters from the resumed (+ journal-replayed)
175
+ // history now, before the caller renders.
176
+ deps.hydrateSessionUsage?.();
177
+
178
+ if (meta.model) {
179
+ if (deps.selectModel) {
180
+ try {
181
+ const selected = await deps.selectModel(meta.model);
182
+ deps.runtime.model = selected.registryKey;
183
+ deps.runtime.provider = selected.providerId;
184
+ } catch {
185
+ deps.runtime.model = meta.model; // model may not exist locally
186
+ }
187
+ } else {
188
+ deps.runtime.model = meta.model;
189
+ }
190
+ }
191
+ if (meta.provider) deps.runtime.provider = meta.provider;
192
+
193
+ const panels = reopenPanelsWithModalSkip(
194
+ deps.panelManager,
195
+ meta.returnContext?.openPanels,
196
+ deps.panelReopenLimit ?? DEFAULT_PANEL_REOPEN_LIMIT,
197
+ );
198
+
199
+ return {
200
+ meta,
201
+ resumedMessageCount: deps.conversation.getMessageCount(),
202
+ restoredAnchorCount,
203
+ journalReplay,
204
+ panels,
205
+ };
206
+ }
@@ -2,6 +2,8 @@ import type { UiRuntimeEvents } from '@/runtime/index.ts';
2
2
  import { createStreamStallWatchdog } from './stream-stall-watchdog.ts';
3
3
  import { buildRoutingChip } 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';
6
8
 
7
9
  /**
@@ -101,6 +103,14 @@ interface FailoverOptimizer {
101
103
  interface StreamSystemMessageRouter {
102
104
  high(message: string): void;
103
105
  low(message: string): void;
106
+ /**
107
+ * Unconditional conversation delivery (see system-message-router.ts). Used
108
+ * for provider-switch notices: which backend serves a turn — and therefore
109
+ * who bills for it — is never ambient chatter that a routing preference or
110
+ * the noise gate may filter out. Optional so bare test doubles that supply
111
+ * only high/low still type-check; announce() below falls back to high().
112
+ */
113
+ userReceipt?(message: string): void;
104
114
  }
105
115
 
106
116
  /**
@@ -166,8 +176,15 @@ export interface WireStreamEventMetricsOptions {
166
176
  * Callback the caller provides to re-submit the last user turn on a
167
177
  * different provider after a successful failover switch. Called only when
168
178
  * the optimizer is enabled and a viable next provider exists in the chain.
179
+ *
180
+ * The re-submission rolls the conversation back to its pre-submission
181
+ * message count, which deletes everything the failed turn added — including
182
+ * a failover notice appended before the call. So the notice is passed IN
183
+ * rather than emitted before: the caller re-posts it after its rollback, and
184
+ * it survives to be read. Implementations that do not roll back may ignore
185
+ * the argument, but must then post the notice themselves.
169
186
  */
170
- readonly retryTurn?: () => void;
187
+ readonly retryTurn?: (notice?: string) => void;
171
188
  /**
172
189
  * Optional cost catalog for attaching per-1M-token cost information to
173
190
  * the failover notice. When provided and both models have non-zero pricing,
@@ -189,6 +206,22 @@ export interface WireStreamEventMetricsOptions {
189
206
  * callers omit it.
190
207
  */
191
208
  readonly stallThresholdMs?: number | undefined;
209
+ /**
210
+ * Shared holder for the live failover record (core/active-model-identity.ts).
211
+ * Set when failover switches the registry off the user's configured
212
+ * selection, cleared once serving is restored to it. The render frame reads
213
+ * the same object, so the header and footer describe the switch while it is
214
+ * in effect. Omitted by unit tests that only exercise notice content.
215
+ */
216
+ readonly failoverState?: FailoverTurnState;
217
+ /**
218
+ * The user's configured model selection (config `provider.model`) as a
219
+ * registry key. Read at the moment of a failover switch so the turn-end
220
+ * restore targets what the user actually chose — not whatever the registry
221
+ * happened to hold. Without it, failover still works but no restore or
222
+ * divergence marker is possible, so both are skipped rather than guessed at.
223
+ */
224
+ readonly getConfiguredRegistryKey?: () => string | undefined;
192
225
  }
193
226
 
194
227
  /** Result of wireStreamEventMetrics. */
@@ -196,10 +229,12 @@ export interface WireStreamEventMetricsResult {
196
229
  /** Unsubscribe functions; push into the parent unsubs array for cleanup on exit. */
197
230
  readonly unsubs: ReadonlyArray<() => void>;
198
231
  /**
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).
232
+ * Clear the per-turn failover visited-provider set AND restore the user's
233
+ * configured model selection if a failover left serving somewhere else.
234
+ * Call this on every new user submission so neither the visited set nor a
235
+ * per-turn provider switch bleeds across independent turns (both are also
236
+ * handled on TURN_COMPLETED/TURN_CANCEL, but a new submission may arrive
237
+ * before either fires).
203
238
  */
204
239
  readonly clearFailoverVisited: () => void;
205
240
  /**
@@ -253,6 +288,33 @@ function buildCostDeltaSuffix(
253
288
  return ` [cost/1M: input ${fmt(fromCost.input)}→${fmt(toCost.input)}, output ${fmt(fromCost.output)}→${fmt(toCost.output)}]`;
254
289
  }
255
290
 
291
+ /**
292
+ * Build the billing-class segment of a failover notice, e.g.
293
+ * ` [billing: API key → Subscription — billing class changed]`.
294
+ *
295
+ * This path is NOT the synthetic provider's tier-isolated failover, which is
296
+ * where the documented "free, paid and subscription tiers never mix" contract
297
+ * lives (docs/providers-and-routing.md:77-128, enforced in the SDK's
298
+ * synthetic.ts by CanonicalModel.tier). The optimizer chain consumed here
299
+ * carries no tier metadata at all — its nodes are
300
+ * `{ position, providerId, modelId, capable, explanation }` and `explanation`
301
+ * describes functional capability (streaming, tool calling, context size),
302
+ * never billing. So the switch cannot be constrained by a tier it cannot see;
303
+ * what it CAN do is say out loud which billing class it moved to, so a user
304
+ * who does not want their subscription spent on an automatic retry can object
305
+ * and turn the optimizer off.
306
+ *
307
+ * Classification comes from providers/provider-classification.ts, which is
308
+ * honest about ignorance: an unrecognised provider id reports "Unknown" rather
309
+ * than being quietly assumed safe.
310
+ */
311
+ function buildBillingSuffix(fromProviderId: string, toProviderId: string): string {
312
+ const from = classifyProviderSetup({ providerId: fromProviderId }).setupLabel;
313
+ const to = classifyProviderSetup({ providerId: toProviderId }).setupLabel;
314
+ const changed = from !== to ? ' — billing class changed' : '';
315
+ return ` [billing: ${from} → ${to}${changed}]`;
316
+ }
317
+
256
318
  /**
257
319
  * Wire STREAM_* and TOOL_* runtime events to the provided StreamMetrics object
258
320
  * and install the stream-stall watchdog. The caller owns the metrics object
@@ -273,11 +335,73 @@ export function wireStreamEventMetrics(
273
335
  const {
274
336
  events, metrics, orchestrator, providerRegistry,
275
337
  systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
276
- isApprovalPending, stallThresholdMs,
338
+ isApprovalPending, stallThresholdMs, failoverState, getConfiguredRegistryKey,
277
339
  } = options;
278
340
 
279
341
  const unsubs: Array<() => void> = [];
280
342
 
343
+ /**
344
+ * Deliver a provider-switch notice to the conversation unconditionally.
345
+ * Prefers the router's userReceipt channel, which skips the noise gate and
346
+ * the ui.operationalMessages routing target entirely, so the line cannot be
347
+ * filtered away by a preference and is treated as real content even while
348
+ * the splash still owns the screen. Falls back to high() for bare doubles.
349
+ */
350
+ const announce = (message: string): void => {
351
+ if (systemMessageRouter.userReceipt) systemMessageRouter.userReceipt(message);
352
+ else systemMessageRouter.high(message);
353
+ };
354
+
355
+ /**
356
+ * Put serving back on the user's configured selection after a failed-over
357
+ * turn ends.
358
+ *
359
+ * Failover is per-turn recovery, not a permanent re-selection. Without this,
360
+ * the one setCurrentModel() call that rescued a single failed turn stayed in
361
+ * force for the rest of the session: every later turn ran on the fallback
362
+ * backend while nothing in the session state or the footer had changed to
363
+ * say so. The user's configured selection is authoritative, so the NEXT turn
364
+ * starts from it again. If that backend is still unhealthy, the normal
365
+ * per-turn failover handles it again — visibly, with a fresh notice — which
366
+ * is exactly the behaviour a sticky override was hiding.
367
+ *
368
+ * The record is kept (not cleared) when the restore itself fails, so the
369
+ * header and footer keep reporting the real serving backend rather than
370
+ * claiming a revert that did not happen.
371
+ */
372
+ /**
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.
378
+ */
379
+ let pendingRestoreKey: string | null = null;
380
+
381
+ const restoreConfiguredSelection = (): void => {
382
+ const record = failoverState?.current();
383
+ if (!record || !failoverState) return;
384
+ if (providerRegistry.getCurrentModel().registryKey === record.configuredRegistryKey) {
385
+ failoverState.clear();
386
+ return;
387
+ }
388
+ try {
389
+ pendingRestoreKey = record.configuredRegistryKey;
390
+ providerRegistry.setCurrentModel(record.configuredRegistryKey);
391
+ failoverState.clear();
392
+ announce(`[Failover] Restored ${record.configuredRegistryKey} for the next turn.`);
393
+ } catch (restoreErr) {
394
+ pendingRestoreKey = null;
395
+ logger.debug('failover restore failed', {
396
+ configuredRegistryKey: record.configuredRegistryKey, error: String(restoreErr),
397
+ });
398
+ announce(
399
+ `[Failover] Could not switch back to ${record.configuredRegistryKey}; still serving ${record.servingRegistryKey}.`,
400
+ );
401
+ }
402
+ render();
403
+ };
404
+
281
405
  unsubs.push(events.turns.on('STREAM_START', () => {
282
406
  metrics.startTime = Date.now();
283
407
  metrics.deltaCount = 0;
@@ -318,8 +442,20 @@ export function wireStreamEventMetrics(
318
442
  // (caller clears via clearFailoverVisited(), wired in main.ts).
319
443
  const failoverVisited = new Set<string>();
320
444
 
445
+ // Both terminal turn outcomes end the failover's authority over the
446
+ // registry: a completed turn got its answer, a cancelled one will not.
447
+ // (TURN_COMPLETED and TURN_ERROR are mutually exclusive per turn in the SDK
448
+ // — TURN_COMPLETED is emitted only on the success path in
449
+ // orchestrator-turn-helpers, TURN_ERROR only from the orchestrator's catch —
450
+ // so restoring here can never undo a switch whose retry has not run yet.)
321
451
  unsubs.push(events.turns.on('TURN_COMPLETED', () => {
322
452
  failoverVisited.clear();
453
+ restoreConfiguredSelection();
454
+ }));
455
+
456
+ unsubs.push(events.turns.on('TURN_CANCEL', () => {
457
+ failoverVisited.clear();
458
+ restoreConfiguredSelection();
323
459
  }));
324
460
 
325
461
  // Routing chip (never-silent model change): every MODEL_CHANGED that is not
@@ -330,6 +466,11 @@ export function wireStreamEventMetrics(
330
466
  // Degrade gracefully when a bare test double omits the providers feed.
331
467
  if (events.providers) {
332
468
  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;
333
474
  queueMicrotask(() => {
334
475
  const chip = buildRoutingChip(change, providerOptimizer?.fallbackLog ?? [], Date.now());
335
476
  if (chip === null) return;
@@ -373,30 +514,45 @@ export function wireStreamEventMetrics(
373
514
  try {
374
515
  providerRegistry.setCurrentModel(toRegistryKey);
375
516
  } catch (switchErr) {
376
- // Switch failed — fall through to honest error display.
517
+ // Switch failed — fall through to honest error display. This ends the
518
+ // turn, so an EARLIER hop's switch (if this is a second failover
519
+ // within the same turn) loses its authority here just as it would on
520
+ // any other terminal outcome.
377
521
  logger.debug('failover setCurrentModel failed', { toRegistryKey, error: String(switchErr) });
378
522
  systemMessageRouter.high(`[Error] ${errorClass}`);
523
+ restoreConfiguredSelection();
379
524
  render();
380
525
  return;
381
526
  }
382
527
  // Record the selected provider as visited before the retry fires so
383
528
  // a subsequent TURN_ERROR from that provider also skips it.
384
529
  failoverVisited.add(next.providerId);
530
+ // Remember the user's configured selection so the turn-end restore
531
+ // targets it, and so both shell surfaces can name it while the switch
532
+ // is in force. Sticky across a second hop within the same turn.
533
+ const configuredRegistryKey = getConfiguredRegistryKey?.();
534
+ if (configuredRegistryKey) {
535
+ failoverState?.begin({ configuredRegistryKey, servingRegistryKey: toRegistryKey });
536
+ }
385
537
  providerOptimizer.recordFallbackTransition(fromProvider, next.providerId, errorClass);
386
538
  const costSuffix = buildCostDeltaSuffix(costLookup, fromRegistryKey, toRegistryKey);
387
- systemMessageRouter.high(
388
- `[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${costSuffix}`,
389
- );
539
+ const billingSuffix = buildBillingSuffix(fromProvider, next.providerId);
540
+ // Re-submit the last user turn on the new provider, handing the notice
541
+ // to retryTurn so it outlives that call's transcript rollback (see the
542
+ // retryTurn option doc). Emitting it here instead would delete it.
543
+ retryTurn(`[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${billingSuffix}${costSuffix}`);
390
544
  render();
391
- // Re-submit the last user turn on the new provider.
392
- retryTurn();
393
545
  return;
394
546
  }
395
547
 
396
548
  // Chain exhausted — all capable candidates have been visited or none exist.
397
- systemMessageRouter.high(
549
+ // The turn is over, so any switch made earlier in it loses its authority:
550
+ // restore the configured selection before surfacing the error, or the
551
+ // user's next turn would silently start on the last fallback tried.
552
+ announce(
398
553
  `[Failover] Chain exhausted — no alternative provider available. Original error: ${formatUserFacingErrorLine(errVal)}`,
399
554
  );
555
+ restoreConfiguredSelection();
400
556
  notifyErrorSurfaced(true);
401
557
  render();
402
558
  return;
@@ -494,7 +650,11 @@ export function wireStreamEventMetrics(
494
650
  function notifyErrorSurfaced(exhausted: boolean) { _errorSurfacedCb?.(exhausted); }
495
651
  return {
496
652
  unsubs,
497
- clearFailoverVisited: () => failoverVisited.clear(),
653
+ // A new user submission is also a turn boundary: restore here too, so a
654
+ // turn that ended without any terminal event (an aborted stream that
655
+ // emitted neither TURN_COMPLETED nor TURN_CANCEL) still cannot leave the
656
+ // next turn silently pinned to a fallback backend.
657
+ clearFailoverVisited: () => { failoverVisited.clear(); restoreConfiguredSelection(); },
498
658
  onErrorSurfaced: (cb: (exhausted: boolean) => void) => { _errorSurfacedCb = cb; },
499
659
  };
500
660
  }
@@ -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
  // ---------------------------------------------------------------------------
@@ -67,6 +67,7 @@ import {
67
67
  } from 'node:fs';
68
68
  import { dirname, join } from 'node:path';
69
69
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
70
+ import type { SessionSurface } from '@/runtime/index.ts';
70
71
 
71
72
  // ─── Constants ──────────────────────────────────────────────────────────────
72
73
 
@@ -128,6 +129,17 @@ export interface TranscriptJournal {
128
129
 
129
130
  /** Absolute path to the journal file. */
130
131
  readonly path: string;
132
+
133
+ /**
134
+ * Point this journal at a different session's file, resetting the sequence
135
+ * counter and initialization flag. Used when the session a journal was
136
+ * opened for is switched out from under it (e.g. `/session resume` or
137
+ * `/session fork` reassigning `runtime.sessionId`) — without this, the
138
+ * journal keeps appending the NEW session's records into the OLD session's
139
+ * file. Does not touch the old file on disk; it is simply no longer
140
+ * written to.
141
+ */
142
+ rebind(journalPath: string, sessionId: string): void;
131
143
  }
132
144
 
133
145
  // ─── Factory ────────────────────────────────────────────────────────────────
@@ -148,11 +160,15 @@ export function openTranscriptJournal(
148
160
  /**
149
161
  * Build the canonical journal path for a session.
150
162
  *
151
- * @param homeDirectory The goodvibes home directory (e.g. ~/.goodvibes).
152
- * @param sessionId The session identifier.
163
+ * Both the home directory and the scope segment come off the caller's
164
+ * SessionSurface, so the journal lands beside the sessions and recovery
165
+ * snapshots it fills gaps for rather than under a separately-spelled scope.
166
+ *
167
+ * @param surface The app's declare-once session-storage handle.
168
+ * @param sessionId The session identifier.
153
169
  */
154
- export function journalPathFor(homeDirectory: string, sessionId: string): string {
155
- return join(homeDirectory, '.goodvibes', 'tui', `transcript-${sessionId}.journal`);
170
+ export function journalPathFor(surface: SessionSurface, sessionId: string): string {
171
+ return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, `transcript-${sessionId}.journal`);
156
172
  }
157
173
 
158
174
  /**
@@ -248,8 +264,11 @@ export function replayJournal(
248
264
  // ─── Implementation ─────────────────────────────────────────────────────────
249
265
 
250
266
  class TranscriptJournalImpl implements TranscriptJournal {
251
- readonly path: string;
252
- private readonly _sessionId: string;
267
+ // Not `readonly` at the class level (unlike the public interface) so
268
+ // rebind() can repoint it; external callers only ever see it through the
269
+ // `TranscriptJournal` interface, which keeps it read-only for them.
270
+ path: string;
271
+ private _sessionId: string;
253
272
  private _seq = 0;
254
273
  private _initialised = false;
255
274
 
@@ -258,6 +277,13 @@ class TranscriptJournalImpl implements TranscriptJournal {
258
277
  this._sessionId = sessionId;
259
278
  }
260
279
 
280
+ rebind(journalPath: string, sessionId: string): void {
281
+ this.path = journalPath;
282
+ this._sessionId = sessionId;
283
+ this._seq = 0;
284
+ this._initialised = false;
285
+ }
286
+
261
287
  appendRecord(type: JournalEventType, messages: ConversationMessageSnapshot[]): void {
262
288
  try {
263
289
  this._ensureInitialised();