@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
@@ -25,11 +25,12 @@ import type { Compositor } from '../renderer/compositor.ts';
25
25
  import type { RuntimeContext, BootstrapOptions } from './context.ts';
26
26
  import type { SystemMessageRouter } from '../core/system-message-router.ts';
27
27
  import {
28
- shutdownRuntime, fireSessionStart, saveSession, createTaskManager, OpsControlPlane, AcpTaskAdapter,
29
- emitSessionReady, emitSessionStarted, loadLastConversation, writeLastSessionPointer,
28
+ shutdownRuntime, fireSessionStart, createTaskManager, OpsControlPlane, AcpTaskAdapter,
29
+ emitSessionReady, emitSessionStarted, loadLastConversation,
30
30
  scheduleBackgroundMcpDiscovery, startBackgroundProviderRegistration, restoreSavedModel, startExternalServices,
31
31
  type ExternalServicesHandle, type HostServiceStatus, createHttpTransport, createDeferredStartupCoordinator,
32
32
  } from '@/runtime/index.ts';
33
+ import { bindWriteLastSessionPointerToSurface } from './session-pointer-surface.ts';
33
34
  import { foldLegacySpineStore, deriveSpineFooterStatus } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
34
35
  import { createTuiSpineTransport, type SpineSessionsClient } from './session-spine-transport.ts';
35
36
  import { syncMemorySpineToHostStatus, type MemorySpineActiveRef } from './memory-spine-transport.ts';
@@ -103,8 +104,6 @@ export type BootstrapContext = RuntimeContext & {
103
104
  loadLastConversation: () => { messages: Array<Record<string, unknown>> } | null;
104
105
  /** Write the last-session pointer file (used after session resume). */
105
106
  _writeLastSessionPointer: (sessionId: string) => void;
106
- /** Save a conversation snapshot to disk. */
107
- _saveSession: typeof saveSession;
108
107
  /** Retrieve pinned model IDs for the model picker. */
109
108
  _getPinned: () => Promise<string[]>;
110
109
  /** Retrieve configured provider IDs for the model picker. */
@@ -272,6 +271,12 @@ export async function bootstrapRuntime(
272
271
  ? new OpsControlPlane(opsTaskManager, runtimeBus, store, userSessionId)
273
272
  : undefined;
274
273
 
274
+ // One surface-bound pointer writer, shared by the resume seam below and the
275
+ // context's `_writeLastSessionPointer`. Both slots want a one-argument
276
+ // function; handing them the SDK's two-argument export directly is what
277
+ // silently stopped the pointer from ever being written (see
278
+ // session-pointer-surface.ts).
279
+ const writeSessionPointer = bindWriteLastSessionPointerToSurface(services.surface);
275
280
  const shell = createBootstrapShell({
276
281
  configManager,
277
282
  runtimeBus,
@@ -286,7 +291,7 @@ export async function bootstrapRuntime(
286
291
  onSessionIdChanged: (sessionId) => {
287
292
  runtimeSessionIdRef.value = sessionId;
288
293
  },
289
- writeLastSessionPointer,
294
+ writeLastSessionPointer: writeSessionPointer,
290
295
  getControlPlaneRecentEvents: (limit) => controlPlaneRecentEventsRef.value(limit),
291
296
  toolRegistry,
292
297
  forensicsRegistry,
@@ -308,9 +313,7 @@ export async function bootstrapRuntime(
308
313
  // `void workspaceCheckpointManager.init().catch(() => {})` — local file I/O only,
309
314
  // resolves well before a human can react to the first rendered frame.
310
315
  void announceResumeState({
311
- workingDirectory: services.workingDirectory,
312
- homeDirectory: services.homeDirectory,
313
- surfaceRoot: 'tui',
316
+ surface: services.surface,
314
317
  sessionManager: services.sessionManager,
315
318
  checkpointManager: services.workspaceCheckpointManager,
316
319
  chainHistory: wrfcPersistence.knownChains,
@@ -718,16 +721,8 @@ export async function bootstrapRuntime(
718
721
  orchestratorRefs,
719
722
  setRenderRequest,
720
723
  permissionPromptRef, trustPromptRef,
721
- loadLastConversation: () => loadLastConversation({
722
- workingDirectory: services.workingDirectory,
723
- homeDirectory: services.homeDirectory,
724
- sessionManager: services.sessionManager,
725
- }),
726
- _writeLastSessionPointer: (sessionId) => writeLastSessionPointer(sessionId, {
727
- workingDirectory: services.workingDirectory,
728
- homeDirectory: services.homeDirectory,
729
- }),
730
- _saveSession: saveSession,
724
+ loadLastConversation: () => loadLastConversation({ surface: services.surface }),
725
+ _writeLastSessionPointer: writeSessionPointer,
731
726
  _getPinned: () => services.favoritesStore.getPinned(),
732
727
  _getConfiguredProviderIds: () => services.providerRegistry.getConfiguredProviderIds(),
733
728
  commandRegistry,
@@ -102,6 +102,25 @@ export function codeIndexDbPath(workingDirectory: string): string {
102
102
  return join(workingDirectory, '.goodvibes', 'tui', 'code-index.sqlite');
103
103
  }
104
104
 
105
+ /**
106
+ * Build the runtime's `rerootStores` implementation — called by
107
+ * WorkspaceSwapManager after it has verified a new working directory.
108
+ *
109
+ * Only working-tree-bound stores move. The memory store is deliberately NOT
110
+ * rerooted: it is the home-scoped canonical cross-surface store, and moving
111
+ * it per project would re-silo memory back into the per-project stores that
112
+ * were consolidated away.
113
+ */
114
+ export function createStoreRerooter(deps: {
115
+ readonly codeIndexStore: Pick<CodeIndexStore, 'reroot'>;
116
+ readonly projectIndex: { reroot(dir: string): Promise<unknown> };
117
+ }): (newWorkingDir: string) => Promise<void> {
118
+ return async (newWorkingDir: string): Promise<void> => {
119
+ await deps.codeIndexStore.reroot(newWorkingDir, codeIndexDbPath(newWorkingDir));
120
+ await deps.projectIndex.reroot(newWorkingDir);
121
+ };
122
+ }
123
+
105
124
  /**
106
125
  * Whether the code index's initial build should auto-start on construction.
107
126
  * Exported so /codebase status and the settings modal read the exact same
@@ -28,6 +28,7 @@ import { resolveMemoryVectorDbPath } from '@pellux/goodvibes-sdk/platform/state'
28
28
  import { StoreSnapshotScheduler } from '@pellux/goodvibes-sdk/platform/state/store-snapshots';
29
29
  import { UserPermissionRuleStore } from '@pellux/goodvibes-sdk/platform/permissions';
30
30
  import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
31
+ import type { SessionSurface } from '@/runtime/index.ts';
31
32
 
32
33
  export interface DurabilityServicesInput {
33
34
  readonly configManager: {
@@ -39,10 +40,13 @@ export interface DurabilityServicesInput {
39
40
  readonly providerRegistry: { refreshProviderCredentials(): Promise<void> };
40
41
  readonly memoryDbPath: string;
41
42
  readonly codeIndexDbPath: string;
42
- /** Retention-sweep roots (mirrors the SDK's own createRuntimeServices roots). */
43
- readonly workingDirectory: string;
44
- readonly surfaceRoot: string;
45
- readonly homeDirectory: string;
43
+ /**
44
+ * The app's declare-once storage handle. The retention-sweep roots below
45
+ * are read straight off it rather than re-declared here, so the janitor can
46
+ * never sweep a different scope than the one sessions and recovery
47
+ * snapshots are actually written to.
48
+ */
49
+ readonly surface: SessionSurface;
46
50
  readonly shellPaths: { resolveUserPath(...segments: string[]): string };
47
51
  }
48
52
 
@@ -71,9 +75,9 @@ export function createDurabilityServices(input: DurabilityServicesInput): Durabi
71
75
  // swallows its own errors).
72
76
  operations.runStartupAppendOnlySweep(
73
77
  {
74
- workingDirectory: input.workingDirectory,
75
- surfaceRoot: input.surfaceRoot,
76
- homeDirectory: input.homeDirectory,
78
+ workingDirectory: input.surface.workingDirectory,
79
+ surfaceRoot: input.surface.surfaceRoot,
80
+ homeDirectory: input.surface.homeDirectory,
77
81
  logDir: input.shellPaths.resolveUserPath('logs'),
78
82
  telemetryDir: input.shellPaths.resolveUserPath('telemetry'),
79
83
  },
@@ -285,6 +285,18 @@ export const getRecoveryDir = operations.getRecoveryDir;
285
285
  export const getLastSessionPointerPath = operations.getLastSessionPointerPath;
286
286
  export const writeLastSessionPointer = operations.writeLastSessionPointer;
287
287
  export const readLastSessionPointer = operations.readLastSessionPointer;
288
+ // The declare-once storage handle (platform/runtime/session-surface.ts) plus
289
+ // the two prompted-recovery primitives that only accept it. Every session
290
+ // path in this app derives from one surface built in runtime/services.ts.
291
+ export const createSessionSurface = operations.createSessionSurface;
292
+ export type SessionSurface = Operations.SessionSurface;
293
+ export const consumeRecovery = operations.consumeRecovery;
294
+ export const removeRecoveryPoint = operations.removeRecoveryPoint;
295
+ // Probe for a NAMED session's live snapshot (per-session supersession — see
296
+ // session-recovery.ts's header) without retiring anything. Backs the
297
+ // --continue / --resume pre-resume check in cli/tui-startup.ts.
298
+ export const checkRecoveryForSession = operations.checkRecoveryForSession;
299
+ export type RecoveryFileInfo = Operations.RecoveryFileInfo;
288
300
  export const exportRemoteArtifactForAgent = operations.exportRemoteArtifactForAgent;
289
301
  export const importRemoteArtifact = operations.importRemoteArtifact;
290
302
  export const RemoteRunnerRegistry = operations.RemoteRunnerRegistry;
@@ -18,15 +18,38 @@
18
18
  * are assigned later and read at exit time (injected as getters/setter). The
19
19
  * `unsubs` registry is shared by reference and drained on exit.
20
20
  */
21
+ import { existsSync } from 'node:fs';
21
22
  import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
22
23
  import { createTerminalLifecycle, TERMINAL_ESCAPES } from '@pellux/goodvibes-terminal-shell';
23
24
  import { formatUserFacingErrorLine } from '../core/format-user-error.ts';
24
25
  import { allowTerminalWrite } from './terminal-output-guard.ts';
25
26
  import { buildPersistedSessionContext, deleteRecoveryFile } from '@/runtime/index.ts';
27
+ import type { SessionSurface } from '@/runtime/index.ts';
28
+ import { removeLivenessMarker } from './session-liveness-marker.ts';
26
29
  import type { BootstrapContext } from './bootstrap.ts';
27
30
  import type { InputHandler } from '../input/handler.ts';
28
31
  import type { ConversationMessageSnapshot } from '../core/conversation.ts';
29
32
 
33
+ /** Shutdown grace period before the "saving session…" line prints. Below this, exit stays quiet. */
34
+ const SAVE_NOTICE_AFTER_MS = 300;
35
+ /** Hard shutdown timeout — matches the pre-existing race below (was previously an inline literal). */
36
+ const SHUTDOWN_HARD_TIMEOUT_MS = 3000;
37
+
38
+ /**
39
+ * Whether a recovery snapshot actually exists on disk for this session. The
40
+ * periodic autosave (see recovery-autosave.ts) only writes on a 60s tick and
41
+ * skips empty conversations entirely, so a session that dies sooner than that
42
+ * (or never had anything in it) has no snapshot at all — the exit receipt
43
+ * must not claim otherwise. Never throws: a stat failure just means "absent".
44
+ */
45
+ function defaultRecoverySnapshotExists(surface: SessionSurface, sessionId: string): boolean {
46
+ try {
47
+ return existsSync(surface.recoveryFile(sessionId));
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
30
53
  /** ANSI escape sequences used by the synchronous terminal restore. */
31
54
  export interface ProcessLifecycleAnsi {
32
55
  readonly CLEAR_SCREEN: string;
@@ -59,6 +82,16 @@ export interface ProcessLifecycleDeps {
59
82
  readonly getRecoveryInterval: () => ReturnType<typeof setInterval> | null;
60
83
  readonly setRecoveryInterval: (value: ReturnType<typeof setInterval> | null) => void;
61
84
  readonly getStopSpokenOutputForExit: () => (() => void | Promise<void>) | null;
85
+ /** Overridable for tests; defaults to SAVE_NOTICE_AFTER_MS (300ms). */
86
+ readonly saveNoticeAfterMs?: number;
87
+ /** Overridable for tests; defaults to SHUTDOWN_HARD_TIMEOUT_MS (3000ms). */
88
+ readonly shutdownHardTimeoutMs?: number;
89
+ /**
90
+ * Overridable for tests; defaults to a real filesystem existence check
91
+ * (defaultRecoverySnapshotExists). Never throws — a stat failure reads as
92
+ * "absent" rather than taking the exit path down.
93
+ */
94
+ readonly recoverySnapshotExists?: (surface: SessionSurface, sessionId: string) => boolean;
62
95
  }
63
96
 
64
97
  export interface ProcessLifecycleHandlers {
@@ -95,6 +128,9 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
95
128
  getRecoveryInterval,
96
129
  setRecoveryInterval,
97
130
  getStopSpokenOutputForExit,
131
+ saveNoticeAfterMs = SAVE_NOTICE_AFTER_MS,
132
+ shutdownHardTimeoutMs = SHUTDOWN_HARD_TIMEOUT_MS,
133
+ recoverySnapshotExists = defaultRecoverySnapshotExists,
98
134
  } = deps;
99
135
 
100
136
  const sigintHandler = (): void => getInput().feed('\x03');
@@ -202,6 +238,12 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
202
238
  restoreTerminal();
203
239
  const snapshot = ctx.conversation.toJSON() as { messages: Array<ConversationMessageSnapshot>; timestamp?: number };
204
240
  let shutdownOk = false;
241
+ // Quiet exit stays quiet: only speak up if the save is taking a moment.
242
+ // The terminal is already restored above, so a plain stdout line lands on
243
+ // the shell prompt exactly like any other command's output would.
244
+ const saveNoticeTimer = setTimeout(() => {
245
+ try { stdout.write('saving session…\n'); } catch { /* best-effort */ }
246
+ }, saveNoticeAfterMs);
205
247
  try {
206
248
  // Race the graceful shutdown against a hard timeout — externalServices.stop() can hang
207
249
  // and we must still exit; deferredStartup.drain only budgets 100ms internally.
@@ -211,19 +253,38 @@ export function installProcessLifecycle(deps: ProcessLifecycleDeps): ProcessLife
211
253
  spokenOutputDrain,
212
254
  Promise.race([
213
255
  ctx.shutdown({ ...snapshot, ...buildPersistedSessionContext(snapshot.messages, ctx.conversation.getTitleSource(), buildSessionContinuityHints()) }).then(() => { shutdownOk = true; }),
214
- new Promise<void>((resolve) => setTimeout(resolve, 3000)),
256
+ new Promise<void>((resolve) => setTimeout(resolve, shutdownHardTimeoutMs)),
215
257
  ]),
216
258
  ]);
217
259
  } catch (err) {
218
260
  logger.debug('ctx.shutdown error during exitApp (non-fatal)', { error: summarizeError(err) });
219
261
  }
262
+ clearTimeout(saveNoticeTimer);
220
263
  // Only remove the recovery fallback once the durable shutdown save actually completed,
221
264
  // so an interrupted or timed-out shutdown leaves the snapshot for the next launch.
222
265
  // Scoped to THIS session's id: without it, one session's clean exit wipes
223
266
  // every session's crash snapshot (the concurrent-session isolation bug).
224
267
  if (shutdownOk) {
225
- deleteRecoveryFile({ homeDirectory: ctx.services.homeDirectory }, ctx.runtime.sessionId);
268
+ deleteRecoveryFile({ surface: ctx.services.surface }, ctx.runtime.sessionId);
269
+ } else {
270
+ // The hard timeout won the race — the durable save never confirmed, so
271
+ // whatever the periodic autosave last wrote (see recovery-autosave.ts)
272
+ // is deliberately kept above. But that autosave only ticks every 60s
273
+ // and skips empty conversations, so a session that dies sooner than
274
+ // that (or never had anything in it) has no snapshot to keep — check
275
+ // before claiming otherwise, since a silent exit here previously left
276
+ // no trace that anything was amiss.
277
+ const kept = recoverySnapshotExists(ctx.services.surface, ctx.runtime.sessionId);
278
+ const line = kept
279
+ ? 'exit before save completed — a recovery snapshot was kept for next launch\n'
280
+ : 'exit before save completed — no recovery snapshot had been written yet\n';
281
+ try { stdout.write(line); } catch { /* best-effort */ }
226
282
  }
283
+ // Best-effort, unconditional: this process is exiting either way, so its
284
+ // multi-instance liveness marker (see session-liveness-marker.ts) should
285
+ // stop claiming the session is live regardless of whether the durable
286
+ // save itself completed in time.
287
+ removeLivenessMarker(ctx.services.surface, ctx.runtime.sessionId);
227
288
  process.exit(0);
228
289
  };
229
290
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * recovery-autosave.ts — periodic recovery-file autosave + liveness-marker
3
+ * refresh, extracted out of main.ts to keep it under the architecture
4
+ * line-count gate.
5
+ *
6
+ * Two things happen on the same cadence (default 60s):
7
+ * 1. The existing crash-recovery snapshot write (writeRecoveryFile).
8
+ * 2. The liveness marker refresh (see session-liveness-marker.ts) that lets
9
+ * a second instance tell "this session is open in another terminal
10
+ * right now" from "this is an orphaned crash snapshot".
11
+ * The marker is also written once immediately (not just on the first tick of
12
+ * the interval) so a second instance checking soon after this one launches
13
+ * sees it promptly rather than waiting up to a minute.
14
+ */
15
+ import { buildPersistedSessionContext, writeRecoveryFile } from '@/runtime/index.ts';
16
+ import type { SessionSurface } from '@/runtime/index.ts';
17
+ import { writeLivenessMarker } from './session-liveness-marker.ts';
18
+ import type { ConversationManager, ConversationMessageSnapshot } from '../core/conversation.ts';
19
+
20
+ export interface StartRecoveryAutosaveOptions {
21
+ readonly conversation: ConversationManager;
22
+ readonly runtime: { readonly sessionId: string };
23
+ /**
24
+ * The app's declare-once session-storage handle. Both the snapshot and the
25
+ * liveness marker resolve off it, so the startup recovery offer — which
26
+ * reads through the same handle — looks in the directory this writer used.
27
+ */
28
+ readonly surface: SessionSurface;
29
+ readonly buildSessionContinuityHints: () => Parameters<typeof buildPersistedSessionContext>[2];
30
+ /** Overridable for tests. Defaults to 60s. */
31
+ readonly intervalMs?: number;
32
+ }
33
+
34
+ export function startRecoveryAutosave(options: StartRecoveryAutosaveOptions): ReturnType<typeof setInterval> {
35
+ const { conversation, runtime, surface, buildSessionContinuityHints, intervalMs = 60_000 } = options;
36
+ writeLivenessMarker(surface, runtime.sessionId);
37
+ return setInterval(() => {
38
+ const snapshot = conversation.toJSON() as { messages: Array<ConversationMessageSnapshot> };
39
+ const persisted = buildPersistedSessionContext(snapshot.messages, conversation.getTitleSource(), buildSessionContinuityHints());
40
+ writeRecoveryFile(
41
+ { ...snapshot, ...persisted },
42
+ runtime.sessionId,
43
+ conversation.title ?? '',
44
+ { surface },
45
+ );
46
+ writeLivenessMarker(surface, runtime.sessionId);
47
+ }, intervalMs);
48
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * recovery-decisions.ts — a durable record of the recovery snapshots the user
3
+ * has already said "Remove" to.
4
+ *
5
+ * The defect this exists for: answering Remove deletes the snapshot file, and
6
+ * that was the whole memory of the decision. Anything that puts the file back
7
+ * — a session still running on an older build rewriting its snapshot every
8
+ * 60s, a restored backup, a synced directory — produced the same question
9
+ * again on the next launch, and again after the next removal, with no way for
10
+ * the user to make it stop. A decision the product forgets the instant it acts
11
+ * on it is not a decision the user gets to make.
12
+ *
13
+ * So Remove now writes a record that outlives the process, and a recorded
14
+ * snapshot found on disk again is deleted without asking. "Keep" is
15
+ * deliberately NOT recorded here: Keep means "leave it, ask me next launch",
16
+ * and recovery-prompt.ts's in-process set already holds it for the rest of the
17
+ * run.
18
+ *
19
+ * WHERE IT LIVES, and why not under `surface.stateDir`:
20
+ * `<homeDirectory>/.goodvibes/<surfaceRoot>/recovery-decisions.json` — home-
21
+ * anchored, matching session-liveness-marker.ts, and deliberately NOT the
22
+ * project-anchored `surface.stateDir`. The snapshots this defends against
23
+ * include the ones in the SDK's legacy shared recovery directory, which is
24
+ * itself home-anchored and shared by every project that ever used this
25
+ * surfaceRoot; the current build dual-reads it, so ONE such snapshot is
26
+ * offered at the launch of EVERY project. A project-scoped record could only
27
+ * ever silence one project at a time, which is the same nag wearing a
28
+ * different hat. Records are matched on session id alone; ids are unique, so
29
+ * a record made in one project cannot suppress a different snapshot in
30
+ * another. Each record still carries the workspace it was made in, so the file
31
+ * says plainly where the decision came from.
32
+ */
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
+ import { dirname, join } from 'node:path';
35
+ import type { SessionSurface } from '@/runtime/index.ts';
36
+
37
+ /** One "the user removed this snapshot" decision. */
38
+ export interface RecoveryRemovalRecord {
39
+ readonly sessionId: string;
40
+ /** The project the decision was made in. Provenance — matching is on sessionId alone. */
41
+ readonly workspace: string;
42
+ readonly removedAt: number;
43
+ }
44
+
45
+ /**
46
+ * Bounds on the ledger. It is append-mostly and never read by anything but
47
+ * the boot offer, so it stays small by construction; these are the guards
48
+ * against a pathological case, not a routine cleanup.
49
+ *
50
+ * Note what is deliberately NOT the pruning rule: "drop records whose snapshot
51
+ * file no longer exists". Every record in here describes a file that was just
52
+ * deleted, so that rule would empty the ledger immediately and reinstate the
53
+ * exact defect it was written to fix — the point is precisely to still
54
+ * remember once the file comes back. Age and count are the honest bounds.
55
+ */
56
+ const MAX_RECORDS = 200;
57
+ const RECORD_TTL_MS = 90 * 24 * 60 * 60 * 1000;
58
+
59
+ /** Absolute path to this surface's removal ledger. */
60
+ export function recoveryDecisionsPathFor(surface: SessionSurface): string {
61
+ return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, 'recovery-decisions.json');
62
+ }
63
+
64
+ function isRecord(value: unknown): value is RecoveryRemovalRecord {
65
+ if (typeof value !== 'object' || value === null) return false;
66
+ const candidate = value as Partial<RecoveryRemovalRecord>;
67
+ return typeof candidate.sessionId === 'string'
68
+ && candidate.sessionId.length > 0
69
+ && typeof candidate.workspace === 'string'
70
+ && typeof candidate.removedAt === 'number'
71
+ && Number.isFinite(candidate.removedAt);
72
+ }
73
+
74
+ /**
75
+ * Every removal decision still in force, oldest first. A missing, unreadable
76
+ * or malformed ledger reads as "no decisions recorded" rather than throwing:
77
+ * a corrupt file must not be able to take a boot down, and the worst outcome
78
+ * of losing it is that the user is asked once more.
79
+ */
80
+ export function readRecoveryRemovals(surface: SessionSurface, nowMs: number = Date.now()): RecoveryRemovalRecord[] {
81
+ try {
82
+ const path = recoveryDecisionsPathFor(surface);
83
+ if (!existsSync(path)) return [];
84
+ const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown;
85
+ if (!Array.isArray(parsed)) return [];
86
+ return parsed.filter(isRecord).filter((record) => nowMs - record.removedAt < RECORD_TTL_MS);
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Has the user already chosen Remove for this snapshot's session, in this or
94
+ * any earlier run?
95
+ */
96
+ export function isRecoveryRemovalRecorded(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): boolean {
97
+ if (!sessionId.trim()) return false;
98
+ return readRecoveryRemovals(surface, nowMs).some((record) => record.sessionId === sessionId);
99
+ }
100
+
101
+ /**
102
+ * Record that the user chose Remove for `sessionId`. Idempotent — a repeat
103
+ * removal of a snapshot that came back refreshes the existing record's
104
+ * timestamp rather than adding a second one, so the ledger cannot grow by one
105
+ * entry per reappearance.
106
+ *
107
+ * Best-effort: a ledger that cannot be written must not break the removal the
108
+ * user actually asked for. The cost of a failed write is one more question on
109
+ * a future launch, which is exactly the behaviour that shipped before this
110
+ * file existed.
111
+ */
112
+ export function recordRecoveryRemoval(surface: SessionSurface, sessionId: string, nowMs: number = Date.now()): void {
113
+ if (!sessionId.trim()) return;
114
+ try {
115
+ const kept = readRecoveryRemovals(surface, nowMs).filter((record) => record.sessionId !== sessionId);
116
+ kept.push({ sessionId, workspace: surface.workingDirectory, removedAt: nowMs });
117
+ // Oldest decisions fall off first; the newest are the ones a reappearing
118
+ // snapshot is most likely to match.
119
+ const bounded = kept.slice(-MAX_RECORDS);
120
+ const path = recoveryDecisionsPathFor(surface);
121
+ mkdirSync(dirname(path), { recursive: true });
122
+ writeFileSync(path, JSON.stringify(bounded, null, 2) + '\n', { mode: 0o600 });
123
+ } catch {
124
+ // Best-effort by construction — see the doc comment above.
125
+ }
126
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * recovery-offer-wiring.ts — binds the startup recovery offer
3
+ * (runtime/recovery-prompt.ts) to this shell's live objects.
4
+ *
5
+ * Kept out of main.ts so the entrypoint stays under the architecture
6
+ * line-count gate, and kept out of recovery-prompt.ts so that module stays a
7
+ * pure flow with injectable edges (which is what makes it testable without a
8
+ * booted runtime).
9
+ *
10
+ * What accepting a snapshot actually does, and why each step is here:
11
+ * - `runtime.sessionId` moves to the snapshot's session FIRST. The
12
+ * transcript journal rebinds on that field (turn-event-wiring.ts), and
13
+ * every later turn snapshot keys off it. Without this the restored
14
+ * conversation would be written back under the fresh boot session id —
15
+ * the recovery point would be gone and its content would live somewhere
16
+ * the user never asked for.
17
+ * - The conversation is rebuilt through core/session-recovery.ts's
18
+ * `applyRecoverySnapshot`, which runs the same reset/fromJSON/rebuild +
19
+ * journal-replay sequence a normal resume runs.
20
+ * - The last-session pointer is written, so `--continue` on the next launch
21
+ * reaches the session the user just recovered.
22
+ * - Footer token counters are rehydrated from the restored history, so the
23
+ * numbers on screen describe the conversation actually in memory.
24
+ */
25
+ import { applyRecoverySnapshot, type RecoverySnapshotPayload } from '../core/session-recovery.ts';
26
+ import type { RecoveryPromptDeps } from './recovery-prompt.ts';
27
+ import type { ConversationManager, ConversationMessageSnapshot } from '../core/conversation.ts';
28
+ import type { CommandContext } from '../input/command-registry.ts';
29
+ import type { SessionSurface } from '@/runtime/index.ts';
30
+ import type { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
31
+
32
+ export interface RecoveryOfferWiringInput {
33
+ readonly surface: SessionSurface;
34
+ readonly sessionManager: Pick<SessionManager, 'save'>;
35
+ readonly runtime: { sessionId: string; model: string; provider: string };
36
+ readonly conversation: ConversationManager;
37
+ readonly commandContext: Pick<CommandContext, 'openSelection'> & {
38
+ readonly session?: { readonly hydrateSessionUsage?: (() => void) | undefined } | undefined;
39
+ };
40
+ readonly writeLastSessionPointer: (sessionId: string) => void;
41
+ readonly receipt: (line: string) => void;
42
+ readonly render: () => void;
43
+ }
44
+
45
+ export function buildRecoveryOfferWiring(input: RecoveryOfferWiringInput): RecoveryPromptDeps {
46
+ return {
47
+ surface: input.surface,
48
+ // Read lazily: wireShellUiOpeners patches `openSelection` onto the command
49
+ // context after this wiring is built, and the offer runs later still.
50
+ get openSelection() { return input.commandContext.openSelection; },
51
+ receipt: input.receipt,
52
+ render: input.render,
53
+ applySnapshot: ({ snapshot, sessionId }) => {
54
+ input.runtime.sessionId = sessionId;
55
+ const payload = snapshot as unknown as RecoverySnapshotPayload;
56
+ const persist = (messages: ConversationMessageSnapshot[]): void => {
57
+ input.sessionManager.save(sessionId, messages as never[], {
58
+ title: input.conversation.title || payload.title || '',
59
+ model: input.runtime.model,
60
+ provider: input.runtime.provider,
61
+ timestamp: Date.now(),
62
+ // The user explicitly chose to recover this crashed session in the
63
+ // modal. Expiring it out from under them on a later retention sweep
64
+ // would undo the exact thing they just asked for.
65
+ saveSource: 'user',
66
+ });
67
+ };
68
+ const { messageCount } = applyRecoverySnapshot({
69
+ snapshot: payload,
70
+ sessionId,
71
+ conversation: input.conversation,
72
+ surface: input.surface,
73
+ persistSnapshot: persist,
74
+ });
75
+ // Unconditional, and the reason is the whole point of this feature: a
76
+ // crash-recovery snapshot is NOT a session-store entry. The snapshot
77
+ // file has just been retired by consumeRecovery, so until this write
78
+ // lands the recovered conversation exists only in memory — the pointer
79
+ // below would name a session nothing could load, and `/session resume
80
+ // <id>` would still not reach it. The gap-closure persist above only
81
+ // fires when the journal actually had records to replay, so it cannot
82
+ // be relied on for this.
83
+ persist(input.conversation.getMessageSnapshot() as ConversationMessageSnapshot[]);
84
+ input.writeLastSessionPointer(sessionId);
85
+ input.commandContext.session?.hydrateSessionUsage?.();
86
+ return messageCount;
87
+ },
88
+ };
89
+ }