@pellux/goodvibes-tui 1.17.0 → 1.19.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 (104) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +5025 -1636
  4. package/package.json +7 -7
  5. package/src/cli/endpoints.ts +50 -4
  6. package/src/cli/help.ts +29 -2
  7. package/src/cli/management-commands.ts +99 -23
  8. package/src/cli/management.ts +0 -2
  9. package/src/cli/service-posture.ts +5 -3
  10. package/src/cli/status.ts +27 -6
  11. package/src/cli/surface-command.ts +29 -15
  12. package/src/core/consolidation-receipt.ts +41 -0
  13. package/src/core/conversation-rendering.ts +14 -5
  14. package/src/core/memory-diagnostics-gateway.ts +99 -0
  15. package/src/core/memory-provenance.ts +67 -0
  16. package/src/core/memory-status.ts +118 -0
  17. package/src/core/pairing-devices.ts +49 -0
  18. package/src/core/pairing-handoff.ts +101 -0
  19. package/src/core/pairing-offers.ts +83 -0
  20. package/src/core/pairing-origin.ts +78 -0
  21. package/src/core/pairing-tailscale-gateway.ts +56 -0
  22. package/src/core/power-chip-source.ts +91 -0
  23. package/src/core/power-status.ts +95 -0
  24. package/src/core/stable-host.ts +171 -0
  25. package/src/core/stream-event-wiring.ts +21 -0
  26. package/src/core/turn-budget-outcome.ts +60 -0
  27. package/src/core/turn-cancellation.ts +39 -0
  28. package/src/core/turn-event-wiring.ts +6 -0
  29. package/src/core/voice-provision-gateway.ts +202 -0
  30. package/src/core/voice-provision-status.ts +197 -0
  31. package/src/daemon/cli.ts +127 -60
  32. package/src/input/command-registry.ts +21 -0
  33. package/src/input/commands/acp-runtime.ts +72 -0
  34. package/src/input/commands/devices-runtime.ts +109 -0
  35. package/src/input/commands/experience-runtime.ts +25 -4
  36. package/src/input/commands/git-runtime.ts +28 -4
  37. package/src/input/commands/graph-runtime.ts +45 -0
  38. package/src/input/commands/health-runtime.ts +15 -1
  39. package/src/input/commands/operator-rpc.ts +32 -11
  40. package/src/input/commands/power-runtime.ts +56 -0
  41. package/src/input/commands/qrcode-runtime.ts +14 -19
  42. package/src/input/commands/queue-runtime.ts +70 -0
  43. package/src/input/commands/workstream-attempts.ts +6 -1
  44. package/src/input/commands/workstream-runtime.ts +44 -25
  45. package/src/input/commands/worktree-runtime.ts +35 -8
  46. package/src/input/commands.ts +10 -0
  47. package/src/input/config-modal-types.ts +11 -0
  48. package/src/input/config-modal.ts +65 -2
  49. package/src/input/handler-shortcuts.ts +23 -0
  50. package/src/input/keybindings.ts +19 -1
  51. package/src/input/settings-modal-data.ts +31 -37
  52. package/src/input/settings-modal-types.ts +5 -2
  53. package/src/input/tts-settings-actions.ts +27 -8
  54. package/src/input/voice-local-settings.ts +52 -0
  55. package/src/main.ts +42 -42
  56. package/src/panels/builtin/operations.ts +54 -1
  57. package/src/panels/builtin/shared.ts +12 -0
  58. package/src/panels/builtin-modals.ts +49 -32
  59. package/src/panels/diff-panel.ts +34 -1
  60. package/src/panels/fleet-acts.ts +446 -0
  61. package/src/panels/fleet-gateway.ts +117 -0
  62. package/src/panels/fleet-observed-render.ts +128 -0
  63. package/src/panels/fleet-panel-format.ts +120 -4
  64. package/src/panels/fleet-panel-worktree-detail.ts +14 -0
  65. package/src/panels/fleet-panel.ts +60 -44
  66. package/src/panels/fleet-read-model.ts +34 -3
  67. package/src/panels/fleet-spawn.ts +266 -0
  68. package/src/panels/fleet-stop.ts +38 -2
  69. package/src/panels/fleet-tabs.ts +2 -2
  70. package/src/panels/memory-consolidation-gateway.ts +94 -0
  71. package/src/panels/modals/devices-modal.ts +161 -0
  72. package/src/panels/modals/memory-modal.ts +149 -5
  73. package/src/panels/modals/pairing-modal.ts +198 -68
  74. package/src/panels/modals/security-modal.ts +6 -0
  75. package/src/panels/workstream-graph-render.ts +123 -0
  76. package/src/renderer/composer-fragments.ts +69 -0
  77. package/src/renderer/settings-modal-helpers.ts +9 -0
  78. package/src/renderer/settings-modal.ts +4 -2
  79. package/src/renderer/shell-surface.ts +3 -0
  80. package/src/renderer/tool-call.ts +12 -6
  81. package/src/renderer/ui-factory.ts +32 -0
  82. package/src/runtime/bootstrap-command-context.ts +3 -1
  83. package/src/runtime/bootstrap-command-parts.ts +3 -1
  84. package/src/runtime/bootstrap-core.ts +6 -6
  85. package/src/runtime/bootstrap-shell.ts +7 -0
  86. package/src/runtime/code-index-services.ts +15 -1
  87. package/src/runtime/durability-services.ts +36 -1
  88. package/src/runtime/fleet-services.ts +41 -0
  89. package/src/runtime/idle-power-services.ts +85 -0
  90. package/src/runtime/index.ts +2 -0
  91. package/src/runtime/interaction-seams.ts +109 -0
  92. package/src/runtime/knowledge-services.ts +94 -0
  93. package/src/runtime/legacy-daemon-migration.ts +38 -6
  94. package/src/runtime/legacy-daemon-reconcile.ts +439 -0
  95. package/src/runtime/memory-governance-services.ts +79 -0
  96. package/src/runtime/notification-dispatch.ts +28 -0
  97. package/src/runtime/power-keepawake-remote.ts +75 -0
  98. package/src/runtime/process-lifecycle.ts +3 -1
  99. package/src/runtime/services.ts +105 -107
  100. package/src/runtime/ui-services.ts +3 -0
  101. package/src/runtime/voice-setup-services.ts +104 -0
  102. package/src/shell/blocking-input.ts +21 -141
  103. package/src/shell/recovery-input-helpers.ts +53 -166
  104. package/src/version.ts +11 -2
@@ -1,9 +1,4 @@
1
- import type { ConversationManager } from '../core/conversation';
2
1
  import type { PermissionRequest, RememberTier } from '@pellux/goodvibes-sdk/platform/permissions';
3
- import type { SessionSnapshot } from '@/runtime/index.ts';
4
- import type { SystemMessageRouter } from '../core/system-message-router.ts';
5
- import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
6
- import { replayJournalForSession } from '../core/session-recovery.ts';
7
2
  import { applyHunkKey, buildModifiedEditArgs, type HunkSelectionState } from '../permissions/hunk-selection.ts';
8
3
 
9
4
  export type PendingPermissionState = PermissionRequest & {
@@ -43,47 +38,8 @@ export const APPROVAL_INPUT_DEBOUNCE_MS = 350;
43
38
  export type BlockingInputHandlerOptions = {
44
39
  data: string;
45
40
  pendingPermission: PendingPermissionState | null;
46
- recoveryPending: boolean;
47
41
  abortTurn: () => void;
48
- conversation: ConversationManager;
49
- systemMessageRouter: SystemMessageRouter;
50
42
  render: () => void;
51
- loadRecoveryConversation: () => SessionSnapshot | null;
52
- deleteRecoveryFile: () => void;
53
- /**
54
- * Copy the live recovery.jsonl aside to a preserved sibling so the dismiss
55
- * message's promise ("still on disk") survives this session's later 60s
56
- * autosaves, which would otherwise overwrite the shared recovery file with
57
- * the CURRENT (post-dismiss) session's state (W3 Finding 3). Optional so
58
- * every pre-existing test/caller that doesn't care about preservation is
59
- * unaffected; when omitted, dismiss behaves exactly as before.
60
- */
61
- preserveRecoveryFile?: () => { readonly preserved: boolean; readonly replacedPrevious: boolean };
62
- /**
63
- * Absolute home directory used to locate the transcript journal for this
64
- * recovery session. Required for journal replay on Ctrl+R restore.
65
- */
66
- homeDirectory: string;
67
- /**
68
- * The session ID that the recovery file belongs to. Required for journal
69
- * replay so the correct journal path can be resolved.
70
- */
71
- sessionId: string;
72
- /**
73
- * Persist the post-replay snapshot so the WAL gap is durably closed.
74
- * Called with the replayed message list. Best-effort — failures are swallowed
75
- * inside replayJournalForSession.
76
- */
77
- persistSnapshot: (messages: ConversationMessageSnapshot[]) => void;
78
- /**
79
- * Optional callback invoked after Ctrl+R restore to reopen panels captured in
80
- * the recovery snapshot's returnContext. When provided (as wired in main.ts),
81
- * the callback iterates snapshot.returnContext.openPanels and calls
82
- * panelManager.open() for each entry, then panelManager.show() + render() to
83
- * restore the panel posture from the recovered session. When omitted, panel
84
- * posture is not restored.
85
- */
86
- reopenPanels?: (snapshot: SessionSnapshot) => void;
87
43
  /** Injectable clock for the approval-input debounce; defaults to Date.now(). Tests only. */
88
44
  now?: number;
89
45
  };
@@ -91,7 +47,6 @@ export type BlockingInputHandlerOptions = {
91
47
  export type BlockingInputHandlerResult = {
92
48
  handled: boolean;
93
49
  pendingPermission: PendingPermissionState | null;
94
- recoveryPending: boolean;
95
50
  };
96
51
 
97
52
  export function handleBlockingShellInput(
@@ -100,18 +55,8 @@ export function handleBlockingShellInput(
100
55
  const {
101
56
  data,
102
57
  pendingPermission,
103
- recoveryPending,
104
58
  abortTurn,
105
- conversation,
106
- systemMessageRouter,
107
59
  render,
108
- loadRecoveryConversation,
109
- deleteRecoveryFile,
110
- preserveRecoveryFile,
111
- homeDirectory,
112
- sessionId,
113
- persistSnapshot,
114
- reopenPanels,
115
60
  } = options;
116
61
 
117
62
  if (pendingPermission) {
@@ -124,7 +69,7 @@ export function handleBlockingShellInput(
124
69
  const now = options.now ?? Date.now();
125
70
  if (req.openedAt !== undefined && now - req.openedAt < APPROVAL_INPUT_DEBOUNCE_MS) {
126
71
  render();
127
- return { handled: true, pendingPermission, recoveryPending };
72
+ return { handled: true, pendingPermission };
128
73
  }
129
74
 
130
75
  if (req.hunkState) {
@@ -132,16 +77,16 @@ export function handleBlockingShellInput(
132
77
  if (commit === 'apply') {
133
78
  req.resolve(true, false, buildModifiedEditArgs(req, state));
134
79
  render();
135
- return { handled: true, pendingPermission: null, recoveryPending };
80
+ return { handled: true, pendingPermission: null };
136
81
  }
137
82
  if (commit === 'cancel') {
138
83
  req.resolve(false, false);
139
84
  abortTurn();
140
85
  render();
141
- return { handled: true, pendingPermission: null, recoveryPending };
86
+ return { handled: true, pendingPermission: null };
142
87
  }
143
88
  render();
144
- return { handled: true, pendingPermission: { ...req, hunkState: state }, recoveryPending };
89
+ return { handled: true, pendingPermission: { ...req, hunkState: state } };
145
90
  }
146
91
 
147
92
  // Ctrl+C is ALWAYS the hard abort: deny and kill the turn, in every mode.
@@ -149,7 +94,7 @@ export function handleBlockingShellInput(
149
94
  req.resolve(false, false);
150
95
  abortTurn();
151
96
  render();
152
- return { handled: true, pendingPermission: null, recoveryPending };
97
+ return { handled: true, pendingPermission: null };
153
98
  }
154
99
 
155
100
  // Typed-reply mode: deny-with-reason (started by typing on any card) or
@@ -168,7 +113,7 @@ export function handleBlockingShellInput(
168
113
  req.resolve(false, false, undefined, { reason: buffer });
169
114
  }
170
115
  render();
171
- return { handled: true, pendingPermission: null, recoveryPending };
116
+ return { handled: true, pendingPermission: null };
172
117
  }
173
118
  if (data === '\x1b') {
174
119
  if (req.replyMode === 'exec-answer') {
@@ -176,26 +121,26 @@ export function handleBlockingShellInput(
176
121
  // declines the prompt (the run gets the honest unanswered result).
177
122
  if (buffer.length > 0) {
178
123
  render();
179
- return { handled: true, pendingPermission: { ...req, replyBuffer: '' }, recoveryPending };
124
+ return { handled: true, pendingPermission: { ...req, replyBuffer: '' } };
180
125
  }
181
126
  req.resolve(false, false);
182
127
  render();
183
- return { handled: true, pendingPermission: null, recoveryPending };
128
+ return { handled: true, pendingPermission: null };
184
129
  }
185
130
  // Esc leaves deny-reason mode back to the plain card.
186
131
  render();
187
- return { handled: true, pendingPermission: { ...req, replyMode: undefined, replyBuffer: undefined }, recoveryPending };
132
+ return { handled: true, pendingPermission: { ...req, replyMode: undefined, replyBuffer: undefined } };
188
133
  }
189
134
  if (data === '\x7f' || data === '\b') {
190
135
  render();
191
- return { handled: true, pendingPermission: { ...req, replyBuffer: buffer.slice(0, -1) }, recoveryPending };
136
+ return { handled: true, pendingPermission: { ...req, replyBuffer: buffer.slice(0, -1) } };
192
137
  }
193
138
  if (data.length >= 1 && !data.startsWith('\x1b') && data >= ' ') {
194
139
  render();
195
- return { handled: true, pendingPermission: { ...req, replyBuffer: buffer + data }, recoveryPending };
140
+ return { handled: true, pendingPermission: { ...req, replyBuffer: buffer + data } };
196
141
  }
197
142
  render();
198
- return { handled: true, pendingPermission, recoveryPending };
143
+ return { handled: true, pendingPermission };
199
144
  }
200
145
 
201
146
  // Scroll, mouse, PageUp/Down, arrow, and panel-navigation keys — plus a
@@ -204,7 +149,7 @@ export function handleBlockingShellInput(
204
149
  // request stays pending (answer it with y/n or a remember tier when ready);
205
150
  // Ctrl+C above is still the hard abort, and 'n' still denies.
206
151
  if (data.startsWith('\x1b')) {
207
- return { handled: false, pendingPermission, recoveryPending };
152
+ return { handled: false, pendingPermission };
208
153
  }
209
154
 
210
155
  const key = data.toLowerCase().trim();
@@ -212,14 +157,14 @@ export function handleBlockingShellInput(
212
157
  if (key === 'y') {
213
158
  req.resolve(true, false);
214
159
  render();
215
- return { handled: true, pendingPermission: null, recoveryPending };
160
+ return { handled: true, pendingPermission: null };
216
161
  }
217
162
 
218
163
  if (key === 'a') {
219
164
  // Legacy always-this-session choice — the 'session' remember tier.
220
165
  req.resolve(true, true, undefined, { rememberTier: 'session' });
221
166
  render();
222
- return { handled: true, pendingPermission: null, recoveryPending };
167
+ return { handled: true, pendingPermission: null };
223
168
  }
224
169
 
225
170
  // Numbered remember tiers ([1]..[N], most specific first) from the SDK's
@@ -230,10 +175,10 @@ export function handleBlockingShellInput(
230
175
  if (option) {
231
176
  req.resolve(true, option.tier === 'session', undefined, { rememberTier: option.tier });
232
177
  render();
233
- return { handled: true, pendingPermission: null, recoveryPending };
178
+ return { handled: true, pendingPermission: null };
234
179
  }
235
180
  render();
236
- return { handled: true, pendingPermission, recoveryPending };
181
+ return { handled: true, pendingPermission };
237
182
  }
238
183
 
239
184
  if (key === 'n') {
@@ -242,7 +187,7 @@ export function handleBlockingShellInput(
242
187
  // Esc drops focus (handled above) rather than denying.
243
188
  req.resolve(false, false);
244
189
  render();
245
- return { handled: true, pendingPermission: null, recoveryPending };
190
+ return { handled: true, pendingPermission: null };
246
191
  }
247
192
 
248
193
  if (key === 'd') {
@@ -251,7 +196,6 @@ export function handleBlockingShellInput(
251
196
  return {
252
197
  handled: true,
253
198
  pendingPermission: { ...req, detailsExpanded: !req.detailsExpanded },
254
- recoveryPending,
255
199
  };
256
200
  }
257
201
 
@@ -259,76 +203,12 @@ export function handleBlockingShellInput(
259
203
  // becomes the denial feedback, seeded with this first character.
260
204
  if (data.length === 1 && data >= ' ') {
261
205
  render();
262
- return { handled: true, pendingPermission: { ...req, replyMode: 'deny-reason', replyBuffer: data }, recoveryPending };
206
+ return { handled: true, pendingPermission: { ...req, replyMode: 'deny-reason', replyBuffer: data } };
263
207
  }
264
208
 
265
209
  render();
266
- return { handled: true, pendingPermission, recoveryPending };
267
- }
268
-
269
- if (recoveryPending) {
270
- if (data === '\x12') {
271
- const recovery = loadRecoveryConversation();
272
- if (recovery) {
273
- conversation.fromJSON({
274
- messages: recovery.messages as Parameters<typeof conversation.fromJSON>[0]['messages'],
275
- title: recovery.title,
276
- titleSource: recovery.titleSource,
277
- });
278
- // Replay journal records that post-date the recovery snapshot so turns
279
- // written after the last recovery-file write (but before SIGKILL) are
280
- // not silently dropped. snapshotTimestamp=0 when timestamp is absent so
281
- // all journal records are replayed — safer than dropping.
282
- replayJournalForSession({
283
- homeDirectory,
284
- sessionId,
285
- snapshotTimestamp: recovery.timestamp ?? 0,
286
- conversation,
287
- persistSnapshot,
288
- });
289
- reopenPanels?.(recovery);
290
- systemMessageRouter.high('[Recovery] Session restored.');
291
- deleteRecoveryFile();
292
- } else {
293
- systemMessageRouter.high('[Recovery] Failed to restore saved data.');
294
- }
295
- render();
296
- return { handled: true, pendingPermission: null, recoveryPending: false };
297
- }
298
-
299
- if (data === '\x1b' || data === '\x03') {
300
- systemMessageRouter.high('[Recovery] Discarded recovery data.');
301
- deleteRecoveryFile();
302
- render();
303
- return { handled: true, pendingPermission: null, recoveryPending: false };
304
- }
305
-
306
- // Any other key demonstrates the user's intent to ignore the banner and
307
- // keep working — the prompt's own text invites exactly this ("start
308
- // typing to ignore it"). Previously this branch re-posted the same
309
- // '[Recovery] Ctrl+R to restore...' line on EVERY such key and never
310
- // cleared recoveryPending, so a user who took that invitation got a
311
- // fresh [Recovery] line injected into the transcript around every
312
- // character they typed, forever. Dismiss ONCE instead: clear
313
- // recoveryPending so it stops re-asserting, but do NOT delete the
314
- // recovery file — dismiss is not discard, and the file remains
315
- // restorable by the automatic recovery check on the next launch.
316
- //
317
- // W3 Finding 3: the promise below ("still on disk; you will be asked
318
- // again") used to go false silently — main.ts's 60s autosave overwrites
319
- // the single shared recovery.jsonl with the CURRENT session's state
320
- // within a minute. preserveRecoveryFile (when wired) copies it aside
321
- // NOW, while it still holds the dismissed session's data, so the
322
- // promise stays true. If an earlier dismiss's preserved snapshot gets
323
- // replaced by this one, say so honestly instead of silently discarding it.
324
- const preserveResult = preserveRecoveryFile?.();
325
- if (preserveResult?.replacedPrevious) {
326
- systemMessageRouter.low('[Recovery] Replacing the previously preserved (unrestored) snapshot with this one.');
327
- }
328
- systemMessageRouter.high('[Recovery] Dismissed — the unsaved session is still on disk; you will be asked again next time GoodVibes starts here.');
329
- render();
330
- return { handled: false, pendingPermission, recoveryPending: false };
210
+ return { handled: true, pendingPermission };
331
211
  }
332
212
 
333
- return { handled: false, pendingPermission, recoveryPending };
213
+ return { handled: false, pendingPermission };
334
214
  }
@@ -1,19 +1,22 @@
1
1
  /**
2
- * Helper factories for main()'s stdin fast-path: the Ctrl+R recovery
3
- * persistence/panel-reopen callbacks and the one-key error-retry
4
- * affordance. Extracted from main.ts so the entrypoint stays under the
5
- * architecture line ceiling; main() wires these with its live services.
2
+ * Helper factories for main()'s stdin fast-path and startup: the crash-recovery
3
+ * snapshot persistence + panel-reopen callbacks, the SILENT auto-restore, and
4
+ * the one-key error-retry affordance. Extracted from main.ts so the entrypoint
5
+ * stays under the architecture line ceiling; main() wires these with its live
6
+ * services.
7
+ *
8
+ * Recovery is silent (owner ruling): there is no Ctrl+R prompt/banner and no
9
+ * dismiss/preserve dance. At startup autoRestoreRecoverySession restores the
10
+ * newest crash snapshot in place and emits a single one-line receipt. The old
11
+ * `.preserved` sibling machinery is gone — a session's exit deletes only its
12
+ * OWN snapshot (scoped by sessionId), so a concurrent session's snapshot is
13
+ * never touched.
6
14
  */
7
15
 
8
- import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
9
16
  import type { ConversationMessageSnapshot } from '../core/conversation.ts';
10
- import type { SessionReturnContextSummary, SessionSnapshot } from '@/runtime/index.ts';
11
- import {
12
- checkRecoveryFile,
13
- deleteRecoveryFile as deleteLiveRecoveryFile,
14
- getRecoveryFilePath,
15
- loadRecoveryConversation as loadLiveRecoveryConversation,
16
- } from '@/runtime/index.ts';
17
+ import type { SessionSnapshot } from '@/runtime/index.ts';
18
+ import { autoRestoreRecovery } from '@/runtime/index.ts';
19
+ import { replayJournalForSession } from '../core/session-recovery.ts';
17
20
 
18
21
  export interface PersistRecoveryDeps {
19
22
  readonly sessionManager: {
@@ -48,166 +51,50 @@ export function createReopenRecoveryPanels(deps: ReopenPanelsDeps): (snapshot: S
48
51
  };
49
52
  }
50
53
 
51
- // ---------------------------------------------------------------------------
52
- // W3 Finding 3: preserve the dismissed recovery snapshot.
53
- //
54
- // main.ts's 60s autosave (writeRecoveryFile) overwrites the single shared
55
- // recovery.jsonl with the CURRENT session's live state within a minute — so
56
- // the dismiss banner's promise ("still on disk; you will be asked again next
57
- // time") went false silently well before the next launch. Fix: the moment
58
- // the user dismisses (blocking-input.ts), copy recovery.jsonl aside to a
59
- // preserved sibling file (recovery.jsonl.preserved) so it survives this
60
- // session's later autosaves. Startup then checks BOTH the live file and the
61
- // preserved sibling and offers whichever is newer (pickNewestRecoveryInfo),
62
- // bounded to exactly one preserved file — a later dismiss replaces it, and
63
- // that replacement is reported honestly (PreserveRecoveryResult.replacedPrevious)
64
- // rather than silently dropping the older snapshot.
65
- // ---------------------------------------------------------------------------
66
-
67
- export interface RecoveryFileDeps {
54
+ export interface AutoRestoreRecoveryDeps {
55
+ readonly workingDirectory: string;
68
56
  readonly homeDirectory: string;
69
- readonly surfaceRoot?: string | undefined;
70
- }
71
-
72
- export interface PreservedRecoveryInfo {
73
- readonly title: string;
74
- readonly timestamp: number;
75
- readonly sessionId: string;
76
- readonly returnContext?: SessionReturnContextSummary | undefined;
77
- }
78
-
79
- export interface PreserveRecoveryResult {
80
- /** True if a live recovery.jsonl existed and was copied to the preserved sibling. */
81
- readonly preserved: boolean;
82
- /** True if an existing preserved sibling (from an earlier dismiss) was overwritten. */
83
- readonly replacedPrevious: boolean;
84
- }
85
-
86
- function preservedRecoveryPath(deps: RecoveryFileDeps): string {
87
- return `${getRecoveryFilePath(deps.homeDirectory, deps.surfaceRoot)}.preserved`;
88
- }
89
-
90
- function readRecoveryMetaLine(path: string): { title?: string; timestamp?: number; sessionId?: string; returnContext?: SessionReturnContextSummary } | null {
91
- try {
92
- if (!existsSync(path)) return null;
93
- const firstLine = readFileSync(path, 'utf-8').split('\n')[0];
94
- if (!firstLine) return null;
95
- return JSON.parse(firstLine) as { title?: string; timestamp?: number; sessionId?: string; returnContext?: SessionReturnContextSummary };
96
- } catch {
97
- return null;
98
- }
99
- }
100
-
101
- /** Copy the live recovery.jsonl aside to the preserved sibling. Called once, at dismiss. */
102
- export function createPreserveRecoveryFile(deps: RecoveryFileDeps): () => PreserveRecoveryResult {
103
- return () => {
104
- const live = getRecoveryFilePath(deps.homeDirectory, deps.surfaceRoot);
105
- const preserved = preservedRecoveryPath(deps);
106
- if (!existsSync(live)) return { preserved: false, replacedPrevious: false };
107
- const replacedPrevious = existsSync(preserved);
108
- try {
109
- copyFileSync(live, preserved);
110
- return { preserved: true, replacedPrevious };
111
- } catch {
112
- return { preserved: false, replacedPrevious: false };
113
- }
57
+ /** The live conversation to restore INTO (fromJSON applies the snapshot). */
58
+ readonly conversation: {
59
+ fromJSON(input: { messages: ConversationMessageSnapshot[]; title?: string; titleSource?: SessionSnapshot['titleSource'] }): void;
114
60
  };
61
+ /** Persist the post-replay snapshot so the WAL gap is durably closed. */
62
+ readonly persistSnapshot: (msgs: ConversationMessageSnapshot[]) => void;
63
+ /** Reopen the restored session's panels. */
64
+ readonly reopenPanels: (snapshot: SessionSnapshot) => void;
65
+ /** Surface the one-line receipt (the only user-visible sign of a restore). */
66
+ readonly systemMessageRouter: { high(message: string): void };
115
67
  }
116
68
 
117
69
  /**
118
- * Startup check for the preserved sibling — mirrors the SDK's
119
- * checkRecoveryFile meta read, but for the fixed preserved path. No
120
- * mtime-vs-last-clean-save gate: the preserved file is inert (never
121
- * autosaved-over) until explicitly replaced or cleared, so its mere
122
- * existence is actionable.
123
- */
124
- export function checkPreservedRecoveryFile(deps: RecoveryFileDeps): PreservedRecoveryInfo | null {
125
- const meta = readRecoveryMetaLine(preservedRecoveryPath(deps));
126
- if (!meta) return null;
127
- return { title: meta.title ?? '', timestamp: meta.timestamp ?? 0, sessionId: meta.sessionId ?? '', returnContext: meta.returnContext };
128
- }
129
-
130
- /** Load the preserved sibling's full snapshot — same JSONL shape as recovery.jsonl since it's a byte-identical copy. */
131
- export function loadPreservedRecoveryConversation(deps: RecoveryFileDeps): SessionSnapshot | null {
132
- const path = preservedRecoveryPath(deps);
133
- try {
134
- if (!existsSync(path)) return null;
135
- const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
136
- if (lines.length < 2) return { messages: [] };
137
- const metaLine = JSON.parse(lines[0]!) as { title?: string; titleSource?: SessionSnapshot['titleSource']; returnContext?: SessionSnapshot['returnContext'] };
138
- return {
139
- title: metaLine.title,
140
- titleSource: metaLine.titleSource,
141
- returnContext: metaLine.returnContext,
142
- messages: lines.slice(1).map((line) => {
143
- const { type: _type, ...rest } = JSON.parse(line) as { type: string } & Record<string, unknown>;
144
- return rest;
145
- }),
146
- };
147
- } catch {
148
- return null;
149
- }
150
- }
151
-
152
- /** Remove the preserved sibling — called after it has been restored or discarded. */
153
- export function deletePreservedRecoveryFile(deps: RecoveryFileDeps): void {
154
- try {
155
- unlinkSync(preservedRecoveryPath(deps));
156
- } catch {
157
- // missing file is fine
158
- }
159
- }
160
-
161
- /**
162
- * Pick the newer of the live vs. preserved recovery info for the startup
163
- * prompt, tagging which source it came from so the caller knows which
164
- * load/delete pair to use. Null when neither exists.
165
- */
166
- export function pickNewestRecoveryInfo<T extends { readonly timestamp: number }>(
167
- live: T | null,
168
- preserved: T | null,
169
- ): (T & { readonly source: 'live' | 'preserved' }) | null {
170
- if (!live && !preserved) return null;
171
- if (!live) return { ...(preserved as T), source: 'preserved' };
172
- if (!preserved) return { ...live, source: 'live' };
173
- return preserved.timestamp > live.timestamp
174
- ? { ...(preserved as T), source: 'preserved' }
175
- : { ...live, source: 'live' };
176
- }
177
-
178
- /**
179
- * Startup entry point: resolve which recovery prompt (if any) main.ts should
180
- * show — the live recovery.jsonl, or an earlier dismiss's preserved sibling,
181
- * whichever is newer. Bundles checkRecoveryFile + checkPreservedRecoveryFile
182
- * + pickNewestRecoveryInfo so main.ts's entrypoint stays a one-liner.
70
+ * Silent crash-recovery restore at startup — no prompt, no banner. Loads the
71
+ * newest crash snapshot (the SDK scopes the load AND the follow-up delete to
72
+ * that snapshot's own session id), applies it to the live conversation, replays
73
+ * any journal turns written after the snapshot, reopens its panels, and emits
74
+ * the SDK's one-line receipt. Returns true when a session was restored, false
75
+ * when there was nothing to restore.
183
76
  */
184
- export function resolveStartupRecoveryInfo(
185
- deps: RecoveryFileDeps & { readonly workingDirectory: string },
186
- ): (PreservedRecoveryInfo & { readonly source: 'live' | 'preserved' }) | null {
187
- const live = checkRecoveryFile({ workingDirectory: deps.workingDirectory, homeDirectory: deps.homeDirectory });
188
- const preserved = checkPreservedRecoveryFile(deps);
189
- return pickNewestRecoveryInfo(live, preserved);
190
- }
191
-
192
- export interface RecoveryFileOps {
193
- readonly loadRecoveryConversation: () => SessionSnapshot | null;
194
- readonly deleteRecoveryFile: () => void;
195
- readonly preserveRecoveryFile?: () => PreserveRecoveryResult;
196
- }
197
-
198
- /**
199
- * Build the three source-aware recovery callbacks main.ts's stdin handler
200
- * wires into handleBlockingShellInput. `getSource` is read fresh by
201
- * load/delete on every call (main.ts's recoverySource can change between
202
- * events); preserveRecoveryFile is only ever wired when the CURRENT prompt
203
- * came from the live file (see createPreserveRecoveryFile's doc comment).
204
- */
205
- export function createRecoveryFileOps(getSource: () => 'live' | 'preserved', deps: RecoveryFileDeps): RecoveryFileOps {
206
- return {
207
- loadRecoveryConversation: () => (getSource() === 'preserved' ? loadPreservedRecoveryConversation(deps) : loadLiveRecoveryConversation(deps)),
208
- deleteRecoveryFile: () => (getSource() === 'preserved' ? deletePreservedRecoveryFile(deps) : deleteLiveRecoveryFile(deps)),
209
- preserveRecoveryFile: getSource() === 'live' ? createPreserveRecoveryFile(deps) : undefined,
210
- };
77
+ export function autoRestoreRecoverySession(deps: AutoRestoreRecoveryDeps): boolean {
78
+ const result = autoRestoreRecovery({ workingDirectory: deps.workingDirectory, homeDirectory: deps.homeDirectory });
79
+ if (!result) return false;
80
+ const { snapshot, info, receipt } = result;
81
+ deps.conversation.fromJSON({
82
+ messages: snapshot.messages as ConversationMessageSnapshot[],
83
+ title: snapshot.title,
84
+ titleSource: snapshot.titleSource,
85
+ });
86
+ // Replay journal records that post-date the snapshot so turns written after
87
+ // the last recovery-file write (but before the crash) are not dropped.
88
+ replayJournalForSession({
89
+ homeDirectory: deps.homeDirectory,
90
+ sessionId: info.sessionId,
91
+ snapshotTimestamp: info.timestamp ?? 0,
92
+ conversation: deps.conversation as never,
93
+ persistSnapshot: deps.persistSnapshot,
94
+ });
95
+ deps.reopenPanels(snapshot);
96
+ deps.systemMessageRouter.high(`[Recovery] ${receipt}`);
97
+ return true;
211
98
  }
212
99
 
213
100
  export interface ErrorAffordanceDeps {
package/src/version.ts CHANGED
@@ -6,10 +6,19 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.17.0';
9
+ let _version = '1.19.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
- _version = pkg.version ?? _version;
12
+ // Only trust a version read from OUR OWN package.json. A Bun single-file
13
+ // compiled binary resolves import.meta.dir to a virtual root where the
14
+ // `../package.json` path can land on a DIFFERENT package.json (a bundled
15
+ // dependency's, or the embedded runtime's) that reports a placeholder like
16
+ // "0.0.0" — exactly the wrong-version banner a bare daemon launch showed in
17
+ // the field. Guarding on the package name means the prebuild-baked fallback
18
+ // above wins in that case instead of a stray version.
19
+ if (pkg?.name === '@pellux/goodvibes-tui' && typeof pkg.version === 'string' && pkg.version.length > 0) {
20
+ _version = pkg.version;
21
+ }
13
22
  } catch {
14
23
  // Compiled binary or missing package.json — use fallback
15
24
  }