@pellux/goodvibes-tui 1.19.6 → 1.20.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 (84) hide show
  1. package/CHANGELOG.md +6 -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/ensure-goodvibes-gitignore.ts +16 -4
  8. package/src/cli/entrypoint.ts +5 -1
  9. package/src/cli/launch-auto-update.ts +63 -4
  10. package/src/cli/tui-startup.ts +131 -15
  11. package/src/config/surface.ts +21 -0
  12. package/src/config/tui-extension-settings.ts +9 -1
  13. package/src/core/conversation-line-cache.ts +1 -1
  14. package/src/core/conversation-rendering.ts +70 -24
  15. package/src/core/conversation-tool-groups.ts +23 -10
  16. package/src/core/conversation-types.ts +9 -0
  17. package/src/core/rewind-turn-anchors.ts +12 -13
  18. package/src/core/session-recovery.ts +59 -2
  19. package/src/core/session-resume-core.ts +206 -0
  20. package/src/core/transcript-journal.ts +32 -6
  21. package/src/core/turn-event-wiring.ts +55 -12
  22. package/src/input/command-args-hint.ts +11 -2
  23. package/src/input/command-registry.ts +19 -0
  24. package/src/input/commands/checkpoint-runtime.ts +3 -3
  25. package/src/input/commands/health-runtime.ts +1 -3
  26. package/src/input/commands/local-runtime.ts +1 -1
  27. package/src/input/commands/rewind-runtime.ts +132 -1
  28. package/src/input/commands/runtime-services.ts +11 -1
  29. package/src/input/commands/secret-runtime.ts +1 -1
  30. package/src/input/commands/session-content.ts +9 -1
  31. package/src/input/commands/session-picker-filter.ts +28 -0
  32. package/src/input/commands/session-workflow.ts +62 -73
  33. package/src/input/commands/session.ts +36 -1
  34. package/src/input/commands/shell-core.ts +57 -5
  35. package/src/input/commands/update-runtime.ts +120 -4
  36. package/src/input/commands/websearch-runtime.ts +1 -1
  37. package/src/input/handler-content-actions.ts +63 -39
  38. package/src/input/handler-feed-routes.ts +17 -3
  39. package/src/input/handler-feed.ts +3 -0
  40. package/src/input/handler-interactions.ts +19 -23
  41. package/src/input/handler-modal-token-routes.ts +5 -0
  42. package/src/input/handler-types.ts +4 -1
  43. package/src/input/handler-ui-state.ts +6 -2
  44. package/src/input/handler.ts +19 -9
  45. package/src/input/search.ts +94 -2
  46. package/src/input/selection-modal-queue.ts +176 -0
  47. package/src/main.ts +48 -44
  48. package/src/panels/builtin/operations.ts +2 -1
  49. package/src/panels/provider-health-domains.ts +2 -2
  50. package/src/renderer/block-actions-overlay.ts +59 -0
  51. package/src/renderer/block-actions.ts +1 -3
  52. package/src/renderer/context-status-hint.ts +73 -5
  53. package/src/renderer/conversation-layout.ts +16 -0
  54. package/src/renderer/conversation-overlays.ts +6 -0
  55. package/src/renderer/help-overlay.ts +69 -53
  56. package/src/renderer/selection-modal-overlay.ts +66 -10
  57. package/src/renderer/shell-surface.ts +15 -0
  58. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  59. package/src/renderer/ui-factory.ts +1 -1
  60. package/src/runtime/bootstrap-command-context.ts +5 -1
  61. package/src/runtime/bootstrap-command-parts.ts +5 -2
  62. package/src/runtime/bootstrap-core.ts +3 -3
  63. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  64. package/src/runtime/bootstrap-shell.ts +31 -16
  65. package/src/runtime/bootstrap.ts +13 -18
  66. package/src/runtime/code-index-services.ts +19 -0
  67. package/src/runtime/durability-services.ts +11 -7
  68. package/src/runtime/index.ts +12 -0
  69. package/src/runtime/process-lifecycle.ts +63 -2
  70. package/src/runtime/recovery-autosave.ts +48 -0
  71. package/src/runtime/recovery-offer-wiring.ts +89 -0
  72. package/src/runtime/recovery-prompt.ts +312 -0
  73. package/src/runtime/resume-notice.ts +28 -93
  74. package/src/runtime/services.ts +14 -16
  75. package/src/runtime/session-liveness-marker.ts +120 -0
  76. package/src/runtime/session-pointer-surface.ts +22 -0
  77. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  78. package/src/runtime/session-storage-services.ts +41 -0
  79. package/src/runtime/workspace-checkpointing.ts +10 -2
  80. package/src/shell/recovery-input-helpers.ts +7 -3
  81. package/src/shell/retry-affordance.ts +123 -0
  82. package/src/shell/ui-openers.ts +9 -0
  83. package/src/utils/splash-lines.ts +1 -1
  84. package/src/version.ts +1 -1
@@ -22,6 +22,7 @@ import { createSystemMessageRouter, type SystemMessageRouter } from '../core/sys
22
22
  import { getConfigSnapshot } from '../config/index.ts';
23
23
  import { createBootstrapCommandContext } from './bootstrap-command-context.ts';
24
24
  import { createResumeSessionHandler } from './bootstrap-hook-bridge.ts';
25
+ import { confirmLiveResume } from './session-resume-liveness-confirm.ts';
25
26
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
26
27
  import { loadBootstrapSystemPrompt } from '@/runtime/index.ts';
27
28
  import { createShellPlanRuntime, createShellRemoteCommandService } from '@/runtime/index.ts';
@@ -102,6 +103,24 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
102
103
  orchestrator.usage = usage;
103
104
  orchestrator.lastInputTokens = lastInputTokens;
104
105
  };
106
+ // Built before resumeSession below so its providerApi.selectModel is
107
+ // available for the resume handler's model-reselection step (matches
108
+ // session-workflow.ts's /session resume — see core/session-resume-core.ts).
109
+ const foundationClients = createRuntimeFoundationClients({
110
+ runtimeServices: services,
111
+ tasksReadModel: uiServices.readModels.tasks,
112
+ taskManager,
113
+ opsControlPlane,
114
+ });
115
+ const {
116
+ directTransport,
117
+ hookApi,
118
+ knowledgeApi,
119
+ mcpApi,
120
+ opsApi,
121
+ providerApi,
122
+ } = foundationClients;
123
+
105
124
  const resumeSession = createResumeSessionHandler({
106
125
  runtimeBus,
107
126
  runtime,
@@ -115,26 +134,21 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
115
134
  hookDispatcher: services.hookDispatcher,
116
135
  sessionManager: services.sessionManager,
117
136
  panelManager: services.panelManager,
137
+ surface: services.surface,
138
+ // Read lazily on purpose. The selection modal lives on the command
139
+ // context, which is composed further down this function (and whose
140
+ // openSelection is patched later still, by wireShellUiOpeners); a resume
141
+ // only ever happens long after both. An unpatched opener reads as "no
142
+ // surface to ask through", and the resume proceeds as it always did.
143
+ confirmLiveResume: (sessionId) => confirmLiveResume(sessionId, {
144
+ surface: services.surface,
145
+ openSelection: () => commandContext.openSelection,
146
+ }),
118
147
  configManager,
119
148
  providerRegistry: services.providerRegistry,
120
- homeDirectory: services.homeDirectory,
121
149
  hydrateSessionUsage,
150
+ selectModel: (model) => providerApi.selectModel(model),
122
151
  });
123
-
124
- const foundationClients = createRuntimeFoundationClients({
125
- runtimeServices: services,
126
- tasksReadModel: uiServices.readModels.tasks,
127
- taskManager,
128
- opsControlPlane,
129
- });
130
- const {
131
- directTransport,
132
- hookApi,
133
- knowledgeApi,
134
- mcpApi,
135
- opsApi,
136
- providerApi,
137
- } = foundationClients;
138
152
  const planRuntime = createShellPlanRuntime({
139
153
  adaptivePlanner: services.adaptivePlanner,
140
154
  runtimeBus,
@@ -263,6 +277,7 @@ export function createBootstrapShell(options: BootstrapShellOptions): BootstrapS
263
277
  policyRuntimeState,
264
278
  readModels: uiServices.readModels,
265
279
  shellPaths: services.shellPaths,
280
+ surface: services.surface,
266
281
  remoteRuntime,
267
282
  planRuntime,
268
283
  fileUndoManager: services.fileUndoManager,
@@ -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,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
+ }