@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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +143 -1613
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/bundle-command.ts +1 -1
- package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
- package/src/cli/entrypoint.ts +5 -1
- package/src/cli/launch-auto-update.ts +63 -4
- package/src/cli/management.ts +1 -1
- package/src/cli/tui-startup.ts +131 -15
- package/src/config/surface.ts +21 -0
- package/src/config/tui-extension-settings.ts +9 -1
- package/src/core/active-model-identity.ts +153 -0
- package/src/core/conversation-line-cache.ts +1 -1
- package/src/core/conversation-rendering.ts +70 -24
- package/src/core/conversation-tool-groups.ts +23 -10
- package/src/core/conversation-types.ts +9 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +20 -17
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +59 -2
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/stream-event-wiring.ts +174 -14
- package/src/core/system-message-router.ts +22 -0
- package/src/core/transcript-journal.ts +32 -6
- package/src/core/turn-event-wiring.ts +55 -12
- package/src/input/command-args-hint.ts +11 -2
- package/src/input/command-registry.ts +19 -0
- package/src/input/commands/checkpoint-runtime.ts +3 -3
- package/src/input/commands/health-runtime.ts +1 -3
- package/src/input/commands/local-runtime.ts +1 -1
- package/src/input/commands/rewind-runtime.ts +132 -1
- package/src/input/commands/runtime-services.ts +11 -1
- package/src/input/commands/secret-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +9 -1
- package/src/input/commands/session-picker-filter.ts +28 -0
- package/src/input/commands/session-workflow.ts +62 -73
- package/src/input/commands/session.ts +36 -1
- package/src/input/commands/shell-core.ts +57 -5
- package/src/input/commands/update-runtime.ts +120 -4
- package/src/input/commands/websearch-runtime.ts +1 -1
- package/src/input/handler-content-actions.ts +63 -39
- package/src/input/handler-feed-routes.ts +17 -3
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +19 -23
- package/src/input/handler-modal-token-routes.ts +5 -0
- package/src/input/handler-types.ts +4 -1
- package/src/input/handler-ui-state.ts +6 -2
- package/src/input/handler.ts +19 -9
- package/src/input/search.ts +94 -2
- package/src/input/selection-modal-queue.ts +176 -0
- package/src/main.ts +66 -61
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/{cli → providers}/provider-classification.ts +12 -0
- package/src/renderer/block-actions-overlay.ts +59 -0
- package/src/renderer/block-actions.ts +1 -3
- package/src/renderer/context-status-hint.ts +73 -5
- package/src/renderer/conversation-layout.ts +16 -0
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/header-line.ts +146 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +51 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +26 -79
- package/src/runtime/bootstrap-command-context.ts +5 -1
- package/src/runtime/bootstrap-command-parts.ts +5 -2
- package/src/runtime/bootstrap-core.ts +3 -3
- package/src/runtime/bootstrap-hook-bridge.ts +62 -44
- package/src/runtime/bootstrap-shell.ts +31 -16
- package/src/runtime/bootstrap.ts +13 -18
- package/src/runtime/code-index-services.ts +19 -0
- package/src/runtime/durability-services.ts +11 -7
- package/src/runtime/index.ts +12 -0
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-decisions.ts +126 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +362 -0
- package/src/runtime/resume-notice.ts +28 -93
- package/src/runtime/services.ts +14 -16
- package/src/runtime/session-liveness-marker.ts +120 -0
- package/src/runtime/session-pointer-surface.ts +22 -0
- package/src/runtime/session-resume-liveness-confirm.ts +84 -0
- package/src/runtime/session-storage-services.ts +41 -0
- package/src/runtime/workspace-checkpointing.ts +10 -2
- package/src/shell/recovery-input-helpers.ts +7 -3
- package/src/shell/retry-affordance.ts +123 -0
- package/src/shell/ui-openers.ts +9 -0
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
package/src/runtime/bootstrap.ts
CHANGED
|
@@ -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,
|
|
29
|
-
emitSessionReady, emitSessionStarted, loadLastConversation,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
},
|
package/src/runtime/index.ts
CHANGED
|
@@ -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,
|
|
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({
|
|
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
|
+
}
|