@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.
- package/CHANGELOG.md +6 -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/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/tui-startup.ts +131 -15
- package/src/config/surface.ts +21 -0
- package/src/config/tui-extension-settings.ts +9 -1
- 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/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/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 +48 -44
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- 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/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +15 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +1 -1
- 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-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +312 -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
|
@@ -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,
|
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,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
|
+
}
|