@pellux/goodvibes-tui 0.23.0 → 0.24.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 (64) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +20 -12
  3. package/docs/foundation-artifacts/operator-contract.json +304 -230
  4. package/package.json +2 -2
  5. package/src/cli/management.ts +80 -10
  6. package/src/core/long-task-notifier.ts +145 -0
  7. package/src/core/session-recovery.ts +147 -0
  8. package/src/core/stream-event-wiring.ts +77 -3
  9. package/src/core/transcript-journal.ts +339 -0
  10. package/src/core/turn-event-wiring.ts +67 -4
  11. package/src/input/commands/control-room-runtime.ts +0 -2
  12. package/src/input/commands/diff-runtime.ts +1 -1
  13. package/src/input/commands/eval.ts +1 -1
  14. package/src/input/commands/health-runtime.ts +23 -4
  15. package/src/input/commands/knowledge.ts +1 -1
  16. package/src/input/commands/local-runtime.ts +1 -2
  17. package/src/input/commands/memory-product-runtime.ts +2 -2
  18. package/src/input/commands/memory.ts +1 -1
  19. package/src/input/commands/onboarding-runtime.ts +0 -1
  20. package/src/input/commands/policy.ts +1 -1
  21. package/src/input/commands/profile-sync-runtime.ts +4 -3
  22. package/src/input/commands/provider.ts +1 -1
  23. package/src/input/commands/qrcode-runtime.ts +0 -1
  24. package/src/input/commands/session-content.ts +2 -2
  25. package/src/input/commands/session-workflow.ts +32 -2
  26. package/src/input/commands/session.ts +1 -1
  27. package/src/input/commands/settings-sync-runtime.ts +9 -9
  28. package/src/input/commands/shell-core.ts +2 -2
  29. package/src/input/commands/work-plan-runtime.ts +8 -8
  30. package/src/input/feed-context-factory.ts +6 -0
  31. package/src/input/handler-feed-routes.ts +19 -1
  32. package/src/input/handler-feed.ts +11 -0
  33. package/src/input/handler-prompt-buffer.ts +28 -0
  34. package/src/input/handler-shortcuts.ts +88 -2
  35. package/src/input/handler-ui-state.ts +2 -2
  36. package/src/input/handler.ts +39 -3
  37. package/src/input/keybindings.ts +33 -3
  38. package/src/input/kill-ring.ts +134 -0
  39. package/src/input/model-picker.ts +18 -1
  40. package/src/input/search.ts +18 -6
  41. package/src/input/settings-modal-activation.ts +134 -0
  42. package/src/input/settings-modal-adjustment.ts +124 -0
  43. package/src/input/settings-modal-data.ts +53 -0
  44. package/src/input/settings-modal.ts +48 -145
  45. package/src/main.ts +33 -33
  46. package/src/panels/base-panel.ts +2 -1
  47. package/src/panels/provider-health-domains.ts +3 -3
  48. package/src/panels/provider-health-panel.ts +13 -9
  49. package/src/panels/provider-health-tracker.ts +7 -4
  50. package/src/panels/settings-sync-panel.ts +3 -3
  51. package/src/panels/work-plan-panel.ts +2 -2
  52. package/src/renderer/diff-view.ts +2 -2
  53. package/src/renderer/help-overlay.ts +1 -0
  54. package/src/renderer/model-picker-overlay.ts +23 -11
  55. package/src/renderer/progress.ts +3 -3
  56. package/src/renderer/search-overlay.ts +8 -5
  57. package/src/renderer/settings-modal.ts +1 -1
  58. package/src/renderer/ui-factory.ts +11 -0
  59. package/src/runtime/bootstrap-hook-bridge.ts +18 -0
  60. package/src/runtime/bootstrap-shell.ts +1 -0
  61. package/src/shell/blocking-input.ts +32 -0
  62. package/src/shell/recovery-input-helpers.ts +71 -0
  63. package/src/utils/terminal-width.ts +10 -3
  64. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.23.0",
3
+ "version": "0.24.1",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -99,7 +99,7 @@
99
99
  "@anthropic-ai/vertex-sdk": "^0.16.0",
100
100
  "@ast-grep/napi": "^0.42.0",
101
101
  "@aws/bedrock-token-generator": "^1.1.0",
102
- "@pellux/goodvibes-sdk": "0.33.36",
102
+ "@pellux/goodvibes-sdk": "0.33.38",
103
103
  "bash-language-server": "^5.6.0",
104
104
  "fuse.js": "^7.1.0",
105
105
  "graphql": "^16.13.2",
@@ -6,6 +6,7 @@ import { formatProviderModel, getModelIdFromProviderModel } from '../config/prov
6
6
  import { SecretsManager } from '../config/secrets.ts';
7
7
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
8
8
  import { listProviderRuntimeSnapshots } from '@pellux/goodvibes-sdk/platform/providers';
9
+ import type { CanonicalModel } from '@pellux/goodvibes-sdk/platform/providers';
9
10
  import { BUILTIN_SECRET_PROVIDER_SOURCES, describeSecretRef, isSecretRefInput, resolveSecretRef } from '@pellux/goodvibes-sdk/platform/config';
10
11
  import { getSubscriptionProviderConfig, listAvailableSubscriptionProviders } from '@pellux/goodvibes-sdk/platform/config';
11
12
  import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibes-sdk/platform/config';
@@ -173,6 +174,40 @@ async function renderProviders(runtime: CliCommandRuntime): Promise<string> {
173
174
  });
174
175
  }
175
176
 
177
+ /**
178
+ * Build the synthetic chain entry list for `goodvibes models chain` output.
179
+ * Pure transformation: accepts canonical model data and an optional lowercase filter key,
180
+ * returns a serializable array suitable for both JSON and text formatting.
181
+ *
182
+ * @internal exported for unit testing
183
+ */
184
+ export function buildSyntheticChainEntries(
185
+ canonicalModels: readonly CanonicalModel[],
186
+ filterKey?: string,
187
+ ): Array<{
188
+ id: string;
189
+ tier: string;
190
+ backendCount: number;
191
+ keyedBackendCount: number;
192
+ backends: Array<{ position: number; provider: string; model: string; registryKey: string }>;
193
+ }> {
194
+ const filtered = filterKey
195
+ ? canonicalModels.filter((m) => m.id.toLowerCase().includes(filterKey))
196
+ : canonicalModels;
197
+ return filtered.map((m) => ({
198
+ id: m.id,
199
+ tier: m.tier,
200
+ backendCount: m.backendCount,
201
+ keyedBackendCount: m.keyedBackendCount,
202
+ backends: m.backends.map((b, idx) => ({
203
+ position: idx,
204
+ provider: b.providerName,
205
+ model: b.modelId,
206
+ registryKey: b.registryKey ?? `${b.providerName}:${b.modelId}`,
207
+ })),
208
+ }));
209
+ }
210
+
176
211
  async function renderModels(runtime: CliCommandRuntime): Promise<string> {
177
212
  return await withRuntimeServices(runtime, async (services) => {
178
213
  const [subOrFilter, ...rest] = runtime.cli.commandArgs;
@@ -246,23 +281,58 @@ async function renderModels(runtime: CliCommandRuntime): Promise<string> {
246
281
  ...recent.map((model) => ` ${model}`),
247
282
  ].join('\n'));
248
283
  }
284
+ if (subOrFilter === 'chain' || subOrFilter === 'chains') {
285
+ // List synthetic model fallback ladders — backend composition for each synthetic model.
286
+ const canonicalModels = services.providerRegistry.getSyntheticCanonicalModels();
287
+ if (canonicalModels.length === 0) {
288
+ return formatJsonOrText(runtime.cli)([], 'No synthetic models found in the current catalog.');
289
+ }
290
+ const filterKey = rest[0]?.toLowerCase();
291
+ const filtered = filterKey
292
+ ? canonicalModels.filter((m) => m.id.toLowerCase().includes(filterKey))
293
+ : canonicalModels;
294
+ const value = buildSyntheticChainEntries(filtered);
295
+ return formatJsonOrText(runtime.cli)(value, [
296
+ `GoodVibes synthetic model chains${filterKey ? ` (${filterKey})` : ''}`,
297
+ ...value.flatMap((m) => [
298
+ ` ${m.id} [${m.tier}] ${m.keyedBackendCount}/${m.backendCount} backends configured`,
299
+ ...m.backends.map((b) => ` ${b.position}. ${b.provider}/${b.model}`),
300
+ ]),
301
+ ].join('\n'));
302
+ }
249
303
  const filter = subOrFilter === 'list' ? rest[0]?.toLowerCase() : subOrFilter?.toLowerCase();
250
304
  const models = services.providerRegistry
251
305
  .getSelectableModels()
252
306
  .filter((model) => !filter || model.provider.toLowerCase() === filter || model.registryKey.toLowerCase().includes(filter))
253
307
  .slice(0, 200);
254
- const value = models.map((model) => ({
255
- registryKey: model.registryKey,
256
- provider: model.provider,
257
- ...classifyModelProvider(model.provider),
258
- id: model.id,
259
- displayName: model.displayName,
260
- contextWindow: services.providerRegistry.getContextWindowForModel(model),
261
- current: model.registryKey === current,
262
- }));
308
+ const value = models.map((model) => {
309
+ const synthInfo = model.provider === 'synthetic'
310
+ ? services.providerRegistry.getSyntheticModelInfoFromCatalog(model.id)
311
+ : null;
312
+ return {
313
+ registryKey: model.registryKey,
314
+ provider: model.provider,
315
+ ...classifyModelProvider(model.provider),
316
+ id: model.id,
317
+ displayName: model.displayName,
318
+ contextWindow: services.providerRegistry.getContextWindowForModel(model),
319
+ current: model.registryKey === current,
320
+ ...(synthInfo !== null ? {
321
+ isSynthetic: true,
322
+ syntheticTier: synthInfo.tier,
323
+ syntheticBackends: synthInfo.backendCount,
324
+ syntheticConfiguredBackends: synthInfo.keyedBackendCount,
325
+ } : {}),
326
+ };
327
+ });
263
328
  return formatJsonOrText(runtime.cli)(value, [
264
329
  `GoodVibes models${filter ? ` (${filter})` : ''}`,
265
- ...value.map((model) => ` ${model.current ? '*' : ' '} ${model.registryKey.padEnd(42)} setup=${model.setupClass} ctx=${model.contextWindow.toLocaleString()} ${model.displayName}`),
330
+ ...value.map((model) => {
331
+ const synthLabel = model.isSynthetic
332
+ ? ` [synthetic ${model.syntheticConfiguredBackends}/${model.syntheticBackends}p]`
333
+ : '';
334
+ return ` ${model.current ? '*' : ' '} ${model.registryKey.padEnd(42)} setup=${model.setupClass} ctx=${model.contextWindow.toLocaleString()}${synthLabel} ${model.displayName}`;
335
+ }),
266
336
  ].join('\n'));
267
337
  });
268
338
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * long-task-notifier — fires push notifications when a turn or agent task
3
+ * completes after running longer than the configured threshold.
4
+ *
5
+ * PRIVACY GUARANTEE: Notification text must never include conversation content
6
+ * (user messages, assistant replies, tool outputs). Only metadata is included:
7
+ * task kind, elapsed time, ok/fail status, and session id. This module enforces
8
+ * that constraint by construction — it receives no conversation object and
9
+ * builds all message text from structural metadata only.
10
+ *
11
+ * Delivery targets (in preference order):
12
+ * 1. Desktop notification (linux notify-send / mac osascript) via SDK
13
+ * notifyCompletion — detected and dispatched by the SDK; silently
14
+ * no-ops when the platform does not support it.
15
+ * 2. Configured outbound webhook channel (ntfy topic / webhook URL) via
16
+ * WebhookNotifier.send() — only fires when the user has URLs configured.
17
+ *
18
+ * When neither target is available the function is an honest no-op (debug log
19
+ * only; no user-facing error spam).
20
+ *
21
+ * Focus tracking: terminal focus state is not tracked anywhere in the TUI.
22
+ * Notifications therefore fire regardless of whether the terminal window is
23
+ * focused. A future implementation may suppress notifications when the TUI is
24
+ * in the foreground by reading a focus-state ref. // seam: wire focus ref here.
25
+ */
26
+
27
+ import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
28
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
29
+ import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
30
+
31
+ /** Default threshold in seconds. Turns shorter than this do not notify. */
32
+ export const NOTIFY_AFTER_SECONDS_DEFAULT = 60;
33
+
34
+ /**
35
+ * Sentinel value for the off-state. When behavior.notifyAfterSeconds is 0,
36
+ * push notifications are disabled (same convention as other numeric-off keys
37
+ * in the config schema).
38
+ */
39
+ export const NOTIFY_AFTER_SECONDS_OFF = 0;
40
+
41
+ /** Accepted task kinds for notification messages. */
42
+ export type LongTaskKind = 'turn' | 'agent';
43
+
44
+ /** Completion status for notification messages. */
45
+ export type LongTaskStatus = 'ok' | 'fail';
46
+
47
+ export interface MaybeNotifyLongTaskOptions {
48
+ /**
49
+ * Elapsed milliseconds for the turn or agent task.
50
+ * Must not include any conversation content.
51
+ */
52
+ readonly elapsedMs: number;
53
+
54
+ /** Whether the task completed successfully or failed. */
55
+ readonly status: LongTaskStatus;
56
+
57
+ /** Task kind label for the notification body. */
58
+ readonly kind: LongTaskKind;
59
+
60
+ /** Session id for correlation. Must not be a PII value. */
61
+ readonly sessionId: string;
62
+
63
+ /**
64
+ * Threshold in seconds from config (behavior.notifyAfterSeconds).
65
+ * 0 means off; notifications are suppressed entirely.
66
+ * Should be the raw config value; this function normalises it.
67
+ */
68
+ readonly thresholdSeconds: number;
69
+
70
+ /**
71
+ * Outbound webhook notifier. When provided and the user has URLs
72
+ * configured, the notification is also sent to all configured endpoints
73
+ * (e.g. ntfy.sh topics). Optional — absent means outbound delivery is
74
+ * skipped silently.
75
+ */
76
+ readonly webhookNotifier?: WebhookNotifier | null;
77
+ }
78
+
79
+ /**
80
+ * Fires push notifications for a completed long task if the elapsed time
81
+ * exceeds the configured threshold.
82
+ *
83
+ * Returns true when at least one delivery was attempted, false when the
84
+ * call was a no-op (threshold not reached, or off-state).
85
+ *
86
+ * PRIVACY: builds message text from structural metadata only (kind, elapsed,
87
+ * status, sessionId). Never includes conversation content.
88
+ */
89
+ export function maybeNotifyLongTask(opts: MaybeNotifyLongTaskOptions): boolean {
90
+ const { elapsedMs, status, kind, sessionId, thresholdSeconds, webhookNotifier } = opts;
91
+
92
+ // Off-state: 0 disables notifications entirely.
93
+ if (thresholdSeconds === NOTIFY_AFTER_SECONDS_OFF) {
94
+ logger.debug('long-task-notifier: disabled (threshold=0)');
95
+ return false;
96
+ }
97
+
98
+ // Gate: only notify when the task exceeded the threshold.
99
+ const elapsedSeconds = Math.floor(elapsedMs / 1000);
100
+ if (elapsedSeconds < thresholdSeconds) {
101
+ logger.debug('long-task-notifier: below threshold', { elapsedSeconds, thresholdSeconds });
102
+ return false;
103
+ }
104
+
105
+ // Build concise, metadata-only message. No conversation text.
106
+ const statusLabel = status === 'ok' ? 'completed' : 'failed';
107
+ const title = `GoodVibes — ${kind} ${statusLabel}`;
108
+ // PRIVACY: message contains only structural metadata, never conversation content.
109
+ const message = `${kind} ${statusLabel} in ${elapsedSeconds}s · session ${sessionId.slice(0, 8)}`;
110
+
111
+ // Delivery 1: desktop notification (notify-send on linux, osascript on mac).
112
+ // notifyCompletion is non-throwing; SDK handles platform absence silently.
113
+ try {
114
+ notifyCompletion(title, message, elapsedMs);
115
+ } catch (err) {
116
+ logger.debug('long-task-notifier: desktop notify error', { error: String(err) });
117
+ }
118
+
119
+ // Delivery 2: outbound webhook (ntfy / generic endpoint) if configured.
120
+ if (webhookNotifier) {
121
+ const urls = webhookNotifier.getUrls();
122
+ if (urls.length > 0) {
123
+ webhookNotifier.send(message).catch((err: unknown) => {
124
+ logger.debug('long-task-notifier: webhook send error', { error: String(err) });
125
+ });
126
+ } else {
127
+ logger.debug('long-task-notifier: no webhook URLs configured, skipping outbound delivery');
128
+ }
129
+ }
130
+
131
+ return true;
132
+ }
133
+
134
+ /**
135
+ * Read behavior.notifyAfterSeconds from a config manager.
136
+ * Returns NOTIFY_AFTER_SECONDS_DEFAULT when the key is absent or invalid.
137
+ * Returns NOTIFY_AFTER_SECONDS_OFF (0) when explicitly set to 0.
138
+ */
139
+ export function readNotifyAfterSeconds(configGet: (key: string) => unknown): number {
140
+ const raw = configGet('behavior.notifyAfterSeconds');
141
+ if (raw === 0) return NOTIFY_AFTER_SECONDS_OFF;
142
+ const parsed = typeof raw === 'number' ? raw : Number(raw);
143
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed;
144
+ return NOTIFY_AFTER_SECONDS_DEFAULT;
145
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * session-recovery.ts — Journal replay at session resume.
3
+ *
4
+ * Purpose
5
+ * ───────
6
+ * When a session is resumed, this module checks whether a transcript journal
7
+ * exists for that session whose records post-date the loaded snapshot. If so,
8
+ * it replays those records onto the live conversation and writes a fresh
9
+ * snapshot so the gap is permanently closed.
10
+ *
11
+ * Seams (all three must call replayJournalForSession)
12
+ * ────────────────────────────────────────────────────
13
+ * 1. CLI / command resume — session-workflow.ts, after `fromJSON` +
14
+ * `rebuildHistory` complete. Handles --continue, --resume, /session resume,
15
+ * and --fork.
16
+ * 2. Ctrl+R crash recovery — blocking-input.ts, after `conversation.fromJSON`
17
+ * in the Ctrl+R branch. Handles SIGKILL-era recovery files.
18
+ * 3. In-TUI panel resume — bootstrap-hook-bridge.ts
19
+ * `createResumeSessionHandler`, after `options.runtime.sessionId` is
20
+ * assigned. Handles the session browser / panel-driven resume.
21
+ *
22
+ * Recovery protocol
23
+ * ─────────────────
24
+ * 1. Call replayJournal() with the journal path and the snapshot timestamp.
25
+ * 2. If no records are newer than the snapshot, rotate the (now-stale)
26
+ * journal silently and return.
27
+ * 3. If records are found, apply the final record's messages — each journal
28
+ * record carries the full conversation snapshot at that moment, so the
29
+ * last record by seq is the authoritative post-crash state.
30
+ * 4. Rebuild the conversation history and call the snapshot writer so the
31
+ * gap is durably closed before the user sees the restored conversation.
32
+ * 5. Rotate the journal (it is no longer needed as a gap-filler).
33
+ * 6. Return a result so the caller can emit an honest notice.
34
+ */
35
+
36
+ import { journalPathFor, openTranscriptJournal, replayJournal } from './transcript-journal.ts';
37
+ import type { ConversationManager } from './conversation.ts';
38
+ import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
39
+
40
+ // ─── Types ──────────────────────────────────────────────────────────────────
41
+
42
+ export interface ReplayIntoConversationOptions {
43
+ /** Absolute path to the journal file for this session. */
44
+ readonly journalPath: string;
45
+ /**
46
+ * The `timestamp` field from the loaded session snapshot (SessionMeta).
47
+ * Only journal records with ts > snapshotTimestamp are replayed.
48
+ */
49
+ readonly snapshotTimestamp: number;
50
+ /** The live conversation manager to mutate with replayed messages. */
51
+ readonly conversation: ConversationManager;
52
+ /** Session ID — used when creating the post-replay journal instance for rotate(). */
53
+ readonly sessionId: string;
54
+ /**
55
+ * Persist the restored conversation so the gap is durably closed.
56
+ * Called with the final replayed message list. Best-effort — failures
57
+ * are swallowed so recovery never hard-fails a resume.
58
+ */
59
+ readonly persistSnapshot: (messages: ConversationMessageSnapshot[]) => void;
60
+ }
61
+
62
+ export interface ReplayIntoConversationResult {
63
+ /** Number of journal records that post-dated the snapshot. 0 if nothing to replay. */
64
+ readonly replayed: number;
65
+ /** True if the journal tail was corrupt (quarantined). */
66
+ readonly hadCorruptTail: boolean;
67
+ /** True if the journal had an unrecognised schema version (quarantined). */
68
+ readonly hadVersionMismatch: boolean;
69
+ }
70
+
71
+ // ─── Public API ─────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Replay journal records newer than `snapshotTimestamp` onto `conversation`.
75
+ *
76
+ * Returns a result object so the caller can emit an appropriate notice.
77
+ * Never throws — all errors are swallowed to preserve the "best-effort"
78
+ * recovery contract.
79
+ */
80
+ export function replayJournalIntoConversation(
81
+ options: ReplayIntoConversationOptions,
82
+ ): ReplayIntoConversationResult {
83
+ const { journalPath, snapshotTimestamp, conversation, sessionId, persistSnapshot } = options;
84
+
85
+ try {
86
+ const { records, hadCorruptTail } = replayJournal(journalPath, snapshotTimestamp);
87
+
88
+ // Detect version mismatch: replayJournal quarantines and returns
89
+ // hadCorruptTail=true + 0 records when the header version is wrong.
90
+ // We distinguish it from a genuine corrupt tail by checking whether the
91
+ // journal file still exists (quarantine renames it away in both cases,
92
+ // so we cannot inspect the header at this point). We surface both cases
93
+ // through hadCorruptTail to the caller; hadVersionMismatch is derived
94
+ // from it to give the caller a distinct notice option.
95
+ const hadVersionMismatch = hadCorruptTail && records.length === 0;
96
+
97
+ const journal = openTranscriptJournal(journalPath, sessionId);
98
+
99
+ if (records.length === 0) {
100
+ // Nothing to replay — rotate the (now-stale) journal silently.
101
+ journal.rotate();
102
+ return { replayed: 0, hadCorruptTail, hadVersionMismatch };
103
+ }
104
+
105
+ // The last record (highest seq) holds the most recent full conversation
106
+ // state captured before the crash. Apply it.
107
+ const lastRecord = records[records.length - 1]!;
108
+ const replayedMessages = lastRecord.messages as ConversationMessageSnapshot[];
109
+
110
+ conversation.fromJSON({
111
+ messages: replayedMessages as never[],
112
+ });
113
+ conversation.rebuildHistory();
114
+
115
+ // Write a fresh snapshot so the gap is durably closed even if the
116
+ // process is killed again before the next turn-complete snapshot.
117
+ try {
118
+ persistSnapshot(replayedMessages);
119
+ } catch {
120
+ // Best-effort — never hard-fail recovery due to snapshot write failure.
121
+ }
122
+
123
+ // Rotate the journal — it is no longer needed as a gap-filler.
124
+ journal.rotate();
125
+
126
+ return { replayed: records.length, hadCorruptTail, hadVersionMismatch: false };
127
+ } catch {
128
+ // Absolute last-resort guard — recovery must never crash a resume.
129
+ return { replayed: 0, hadCorruptTail: false, hadVersionMismatch: false };
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Build the journal path for a given session and home directory, then call
135
+ * replayJournalIntoConversation().
136
+ *
137
+ * Convenience wrapper used by session-workflow.ts so it does not need to
138
+ * import journalPathFor directly.
139
+ */
140
+ export function replayJournalForSession(
141
+ options: Omit<ReplayIntoConversationOptions, 'journalPath'> & {
142
+ readonly homeDirectory: string;
143
+ },
144
+ ): ReplayIntoConversationResult {
145
+ const journalPath = journalPathFor(options.homeDirectory, options.sessionId);
146
+ return replayJournalIntoConversation({ ...options, journalPath });
147
+ }
@@ -60,6 +60,15 @@ interface StreamSystemMessageRouter {
60
60
  low(message: string): void;
61
61
  }
62
62
 
63
+ /**
64
+ * Minimal cost lookup surface for attaching cost-delta information to failover notices.
65
+ * Returns USD-per-1M-token pricing for the given model ID.
66
+ * The implementation may consult a catalog; if the model is unknown both fields are 0.
67
+ */
68
+ export interface FailoverCostLookup {
69
+ getCostFromCatalog(modelId: string): { readonly input: number; readonly output: number };
70
+ }
71
+
63
72
  export interface WireStreamEventMetricsOptions {
64
73
  /** The UI runtime event bus (turns + tools sub-buses). */
65
74
  readonly events: UiRuntimeEvents;
@@ -89,6 +98,13 @@ export interface WireStreamEventMetricsOptions {
89
98
  * the optimizer is enabled and a viable next provider exists in the chain.
90
99
  */
91
100
  readonly retryTurn?: () => void;
101
+ /**
102
+ * Optional cost catalog for attaching per-1M-token cost information to
103
+ * the failover notice. When provided and both models have non-zero pricing,
104
+ * the notice includes input and output cost comparisons. When absent or pricing is
105
+ * unavailable for either model, the notice honestly states "cost data unavailable".
106
+ */
107
+ readonly costLookup?: FailoverCostLookup;
92
108
  }
93
109
 
94
110
  /** Result of wireStreamEventMetrics. */
@@ -102,6 +118,53 @@ export interface WireStreamEventMetricsResult {
102
118
  * TURN_COMPLETED, but a new submission may arrive before TURN_COMPLETED fires).
103
119
  */
104
120
  readonly clearFailoverVisited: () => void;
121
+ /**
122
+ * Register a callback that fires whenever a TURN_ERROR is surfaced to the
123
+ * user — either immediately (no optimizer) or after chain exhaustion.
124
+ * Does NOT fire when the optimizer performs a successful automatic failover
125
+ * (in that case the user sees a [Failover] notice, not an error).
126
+ * Used by main.ts to activate the one-key retry affordance. The callback
127
+ * receives exhausted=true when the failover chain was exhausted first, so
128
+ * the notice can say honestly that a retry reuses the same failed provider.
129
+ */
130
+ readonly onErrorSurfaced: (cb: (exhausted: boolean) => void) => void;
131
+ }
132
+
133
+ /**
134
+ * Build the cost-delta suffix for a failover notice.
135
+ *
136
+ * Extracts the model ID from registry keys (format: `provider:modelId`),
137
+ * queries the cost catalog for both, and formats a human-readable comparison.
138
+ * If the lookup is absent or either model returns zero pricing (unknown),
139
+ * returns an honest "cost data unavailable" suffix instead of fabricating values.
140
+ *
141
+ * @param lookup - Optional cost catalog; when absent, returns unavailable notice.
142
+ * @param fromRegistryKey - Registry key of the provider being abandoned (may be undefined).
143
+ * @param toRegistryKey - Registry key of the provider being selected.
144
+ * @returns A parenthesised suffix string or empty string.
145
+ */
146
+ function buildCostDeltaSuffix(
147
+ lookup: FailoverCostLookup | undefined,
148
+ fromRegistryKey: string | undefined,
149
+ toRegistryKey: string,
150
+ ): string {
151
+ if (!lookup) return '';
152
+ // Registry key format: `provider:modelId` — modelId may itself contain `:`.
153
+ const fromModelId = fromRegistryKey ? fromRegistryKey.split(':').slice(1).join(':') : '';
154
+ const toModelId = toRegistryKey.split(':').slice(1).join(':');
155
+ const fromCost = fromModelId ? lookup.getCostFromCatalog(fromModelId) : { input: 0, output: 0 };
156
+ const toCost = lookup.getCostFromCatalog(toModelId);
157
+ // Report unavailable when either side has zero pricing (unknown model).
158
+ if (fromCost.input === 0 && fromCost.output === 0 && !fromModelId) {
159
+ return ' [cost data unavailable]';
160
+ }
161
+ const hasFromData = fromCost.input > 0 || fromCost.output > 0;
162
+ const hasToData = toCost.input > 0 || toCost.output > 0;
163
+ if (!hasFromData || !hasToData) {
164
+ return ' [cost data unavailable]';
165
+ }
166
+ const fmt = (n: number) => `$${n.toFixed(2)}`;
167
+ return ` [cost/1M: input ${fmt(fromCost.input)}→${fmt(toCost.input)}, output ${fmt(fromCost.output)}→${fmt(toCost.output)}]`;
105
168
  }
106
169
 
107
170
  /**
@@ -123,7 +186,7 @@ export function wireStreamEventMetrics(
123
186
  ): WireStreamEventMetricsResult {
124
187
  const {
125
188
  events, metrics, orchestrator, providerRegistry,
126
- systemMessageRouter, render, providerOptimizer, retryTurn,
189
+ systemMessageRouter, render, providerOptimizer, retryTurn, costLookup,
127
190
  } = options;
128
191
 
129
192
  const unsubs: Array<() => void> = [];
@@ -192,6 +255,8 @@ export function wireStreamEventMetrics(
192
255
  if (next) {
193
256
  const toRegistryKey = `${next.providerId}:${next.modelId}`;
194
257
  const errorClass = formatUserFacingErrorLine(errVal);
258
+ // Capture FROM registry key before switching — needed for cost comparison.
259
+ const fromRegistryKey = providerRegistry.getCurrentModel().registryKey;
195
260
  try {
196
261
  providerRegistry.setCurrentModel(toRegistryKey);
197
262
  } catch (switchErr) {
@@ -205,8 +270,9 @@ export function wireStreamEventMetrics(
205
270
  // a subsequent TURN_ERROR from that provider also skips it.
206
271
  failoverVisited.add(next.providerId);
207
272
  providerOptimizer.recordFallbackTransition(fromProvider, next.providerId, errorClass);
273
+ const costSuffix = buildCostDeltaSuffix(costLookup, fromRegistryKey, toRegistryKey);
208
274
  systemMessageRouter.high(
209
- `[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})`,
275
+ `[Failover] ${fromProvider} -> ${next.providerId} (${errorClass})${costSuffix}`,
210
276
  );
211
277
  render();
212
278
  // Re-submit the last user turn on the new provider.
@@ -218,6 +284,7 @@ export function wireStreamEventMetrics(
218
284
  systemMessageRouter.high(
219
285
  `[Failover] Chain exhausted — no alternative provider available. Original error: ${formatUserFacingErrorLine(errVal)}`,
220
286
  );
287
+ notifyErrorSurfaced(true);
221
288
  render();
222
289
  return;
223
290
  }
@@ -225,6 +292,7 @@ export function wireStreamEventMetrics(
225
292
  // Baseline: optimizer disabled or not wired — surface error immediately.
226
293
  const formatted = formatUserFacingErrorLine(errVal);
227
294
  systemMessageRouter.high(`[Error] ${formatted}`);
295
+ notifyErrorSurfaced(false);
228
296
  render();
229
297
  }));
230
298
 
@@ -258,5 +326,11 @@ export function wireStreamEventMetrics(
258
326
  metrics.activeToolName = undefined;
259
327
  }));
260
328
 
261
- return { unsubs, clearFailoverVisited: () => failoverVisited.clear() };
329
+ let _errorSurfacedCb: ((exhausted: boolean) => void) | undefined;
330
+ function notifyErrorSurfaced(exhausted: boolean) { _errorSurfacedCb?.(exhausted); }
331
+ return {
332
+ unsubs,
333
+ clearFailoverVisited: () => failoverVisited.clear(),
334
+ onErrorSurfaced: (cb: (exhausted: boolean) => void) => { _errorSurfacedCb = cb; },
335
+ };
262
336
  }