@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
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
} from 'node:fs';
|
|
68
68
|
import { dirname, join } from 'node:path';
|
|
69
69
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
70
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
70
71
|
|
|
71
72
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
72
73
|
|
|
@@ -128,6 +129,17 @@ export interface TranscriptJournal {
|
|
|
128
129
|
|
|
129
130
|
/** Absolute path to the journal file. */
|
|
130
131
|
readonly path: string;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Point this journal at a different session's file, resetting the sequence
|
|
135
|
+
* counter and initialization flag. Used when the session a journal was
|
|
136
|
+
* opened for is switched out from under it (e.g. `/session resume` or
|
|
137
|
+
* `/session fork` reassigning `runtime.sessionId`) — without this, the
|
|
138
|
+
* journal keeps appending the NEW session's records into the OLD session's
|
|
139
|
+
* file. Does not touch the old file on disk; it is simply no longer
|
|
140
|
+
* written to.
|
|
141
|
+
*/
|
|
142
|
+
rebind(journalPath: string, sessionId: string): void;
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
// ─── Factory ────────────────────────────────────────────────────────────────
|
|
@@ -148,11 +160,15 @@ export function openTranscriptJournal(
|
|
|
148
160
|
/**
|
|
149
161
|
* Build the canonical journal path for a session.
|
|
150
162
|
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
163
|
+
* Both the home directory and the scope segment come off the caller's
|
|
164
|
+
* SessionSurface, so the journal lands beside the sessions and recovery
|
|
165
|
+
* snapshots it fills gaps for rather than under a separately-spelled scope.
|
|
166
|
+
*
|
|
167
|
+
* @param surface The app's declare-once session-storage handle.
|
|
168
|
+
* @param sessionId The session identifier.
|
|
153
169
|
*/
|
|
154
|
-
export function journalPathFor(
|
|
155
|
-
return join(homeDirectory, '.goodvibes',
|
|
170
|
+
export function journalPathFor(surface: SessionSurface, sessionId: string): string {
|
|
171
|
+
return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, `transcript-${sessionId}.journal`);
|
|
156
172
|
}
|
|
157
173
|
|
|
158
174
|
/**
|
|
@@ -248,8 +264,11 @@ export function replayJournal(
|
|
|
248
264
|
// ─── Implementation ─────────────────────────────────────────────────────────
|
|
249
265
|
|
|
250
266
|
class TranscriptJournalImpl implements TranscriptJournal {
|
|
251
|
-
readonly
|
|
252
|
-
|
|
267
|
+
// Not `readonly` at the class level (unlike the public interface) so
|
|
268
|
+
// rebind() can repoint it; external callers only ever see it through the
|
|
269
|
+
// `TranscriptJournal` interface, which keeps it read-only for them.
|
|
270
|
+
path: string;
|
|
271
|
+
private _sessionId: string;
|
|
253
272
|
private _seq = 0;
|
|
254
273
|
private _initialised = false;
|
|
255
274
|
|
|
@@ -258,6 +277,13 @@ class TranscriptJournalImpl implements TranscriptJournal {
|
|
|
258
277
|
this._sessionId = sessionId;
|
|
259
278
|
}
|
|
260
279
|
|
|
280
|
+
rebind(journalPath: string, sessionId: string): void {
|
|
281
|
+
this.path = journalPath;
|
|
282
|
+
this._sessionId = sessionId;
|
|
283
|
+
this._seq = 0;
|
|
284
|
+
this._initialised = false;
|
|
285
|
+
}
|
|
286
|
+
|
|
261
287
|
appendRecord(type: JournalEventType, messages: ConversationMessageSnapshot[]): void {
|
|
262
288
|
try {
|
|
263
289
|
this._ensureInitialised();
|
|
@@ -7,6 +7,7 @@ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
|
|
|
7
7
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
8
8
|
import type { HookDispatcher, HookPhase, HookCategory, HookEventPath } from '@pellux/goodvibes-sdk/platform/hooks';
|
|
9
9
|
import type { ConversationManager } from './conversation.ts';
|
|
10
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
10
11
|
import { journalPathFor, openTranscriptJournal, type TranscriptJournal } from './transcript-journal.ts';
|
|
11
12
|
import type { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
|
|
12
13
|
import { notifyCompletion } from '@pellux/goodvibes-sdk/platform/utils';
|
|
@@ -16,9 +17,6 @@ import { shouldFireAlert, FORCE_NOTIFY_DURATION_MS } from './alert-gating.ts';
|
|
|
16
17
|
import { createBudgetBreachNotifier, type BudgetBreachNotifier } from './budget-breach-notifier.ts';
|
|
17
18
|
import { readBudgetAlertUsd } from '../export/cost-utils.ts';
|
|
18
19
|
|
|
19
|
-
/** Infer the options param of persistConversation to pick up SessionManager correctly. */
|
|
20
|
-
type PersistOptions = NonNullable<Parameters<typeof persistConversation>[5]>;
|
|
21
|
-
|
|
22
20
|
/** Minimal orchestrator surface required by turn-event wiring. */
|
|
23
21
|
interface TurnOrchestrator {
|
|
24
22
|
readonly lastInputTokens: number;
|
|
@@ -64,9 +62,14 @@ export interface WireTurnEventHandlersOptions {
|
|
|
64
62
|
readonly providerRegistry: TurnProviderRegistry;
|
|
65
63
|
readonly systemMessageRouter: TurnSystemMessageRouter;
|
|
66
64
|
readonly hookDispatcher: HookDispatcher;
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
/**
|
|
66
|
+
* The app's declare-once session-storage handle. The turn snapshot, the
|
|
67
|
+
* last-session pointer it writes, the transcript journal, and the rewind
|
|
68
|
+
* anchor sidecar all resolve off this one handle — so the boot notice and
|
|
69
|
+
* `--continue`, which read through the same handle, land on the files this
|
|
70
|
+
* writer actually produced.
|
|
71
|
+
*/
|
|
72
|
+
readonly surface: SessionSurface;
|
|
70
73
|
readonly gitStatusProvider: { refresh(): Promise<unknown> };
|
|
71
74
|
readonly lastGitInfoRef: { value: unknown };
|
|
72
75
|
readonly buildSessionContinuityHints: () => Record<string, unknown>;
|
|
@@ -104,6 +107,32 @@ export interface WireTurnEventHandlersOptions {
|
|
|
104
107
|
readonly _clock?: () => number;
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Wrap a TranscriptJournal so every write first checks whether
|
|
112
|
+
* `runtime.sessionId` has moved on from the session the journal is currently
|
|
113
|
+
* bound to, rebinding to the new session's file when it has. See the
|
|
114
|
+
* `transcriptJournal` construction above for why this is necessary.
|
|
115
|
+
*/
|
|
116
|
+
function wrapJournalWithSessionRebind(
|
|
117
|
+
journal: TranscriptJournal,
|
|
118
|
+
surface: SessionSurface,
|
|
119
|
+
runtime: { readonly sessionId: string },
|
|
120
|
+
): TranscriptJournal {
|
|
121
|
+
let boundSessionId = runtime.sessionId;
|
|
122
|
+
const ensureBoundToCurrentSession = (): void => {
|
|
123
|
+
if (runtime.sessionId !== boundSessionId) {
|
|
124
|
+
boundSessionId = runtime.sessionId;
|
|
125
|
+
journal.rebind(journalPathFor(surface, boundSessionId), boundSessionId);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
get path(): string { return journal.path; },
|
|
130
|
+
appendRecord: (type, messages) => { ensureBoundToCurrentSession(); journal.appendRecord(type, messages); },
|
|
131
|
+
rotate: () => { ensureBoundToCurrentSession(); journal.rotate(); },
|
|
132
|
+
rebind: (path, sessionId) => { boundSessionId = sessionId; journal.rebind(path, sessionId); },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
107
136
|
export interface WireTurnEventHandlersResult {
|
|
108
137
|
/** Trigger a git status refresh; may be called from external code after tool execution. */
|
|
109
138
|
readonly refreshGit: () => void;
|
|
@@ -134,7 +163,7 @@ export function wireTurnEventHandlers(
|
|
|
134
163
|
): WireTurnEventHandlersResult {
|
|
135
164
|
const {
|
|
136
165
|
events, conversation, runtime, configManager, hookDispatcher, orchestrator, providerRegistry,
|
|
137
|
-
|
|
166
|
+
surface, gitStatusProvider,
|
|
138
167
|
lastGitInfoRef, buildSessionContinuityHints, render, webhookNotifier, focusTracker,
|
|
139
168
|
terminalNotifier, runtimeBus, systemMessageRouter,
|
|
140
169
|
_clock = Date.now,
|
|
@@ -145,9 +174,19 @@ export function wireTurnEventHandlers(
|
|
|
145
174
|
|
|
146
175
|
// Create the per-session transcript journal. Path mirrors recovery-file
|
|
147
176
|
// convention (homeDirectory-scoped). Created lazily on first append.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
177
|
+
//
|
|
178
|
+
// `runtime` is the same MutableRuntimeState object /session resume and
|
|
179
|
+
// /session fork reassign `sessionId` on in place (session-workflow.ts,
|
|
180
|
+
// bootstrap-hook-bridge.ts), so a session switch is visible here just by
|
|
181
|
+
// re-reading `runtime.sessionId`. Wrapped so every write re-checks it and
|
|
182
|
+
// rebinds to the new session's file first — otherwise the journal opened
|
|
183
|
+
// for the OLD session keeps appending the NEW session's snapshots into the
|
|
184
|
+
// OLD session's file, and a later resume of the old session replays the
|
|
185
|
+
// wrong conversation.
|
|
186
|
+
const transcriptJournal: TranscriptJournal = wrapJournalWithSessionRebind(
|
|
187
|
+
openTranscriptJournal(journalPathFor(surface, runtime.sessionId), runtime.sessionId),
|
|
188
|
+
surface,
|
|
189
|
+
runtime,
|
|
151
190
|
);
|
|
152
191
|
|
|
153
192
|
// Track turn start time for long-task notification threshold.
|
|
@@ -210,7 +249,11 @@ export function wireTurnEventHandlers(
|
|
|
210
249
|
runtime.model,
|
|
211
250
|
runtime.provider,
|
|
212
251
|
conversation.title || '',
|
|
213
|
-
{
|
|
252
|
+
{ surface },
|
|
253
|
+
// Turn-completion persistence is machinery, not a user act: stated
|
|
254
|
+
// explicitly so the retention sweep can tell it apart from a session
|
|
255
|
+
// the user asked to keep via /session save.
|
|
256
|
+
'auto',
|
|
214
257
|
);
|
|
215
258
|
hookDispatcher.fire({ path: 'Lifecycle:session:save' as HookEventPath, phase: 'Lifecycle' as HookPhase, category: 'session' as HookCategory, specific: 'save', sessionId: runtime.sessionId, timestamp: Date.now(), payload: { sessionId: runtime.sessionId } }).catch((err: unknown) => logger.debug('hook fire error', { error: summarizeError(err) }));
|
|
216
259
|
// Snapshot succeeded — rotate the journal (gap-filler no longer needed).
|
|
@@ -238,7 +281,7 @@ export function wireTurnEventHandlers(
|
|
|
238
281
|
});
|
|
239
282
|
// Mirror the anchors to the session's sidecar so message-anchored /rewind
|
|
240
283
|
// survives a resume (the in-memory registry alone is process-local).
|
|
241
|
-
persistTurnAnchors(runtime.sessionId,
|
|
284
|
+
persistTurnAnchors(runtime.sessionId, surface);
|
|
242
285
|
} catch { /* best-effort; a rewind-anchor miss must never break the turn */ }
|
|
243
286
|
// Auto-compaction is owned by the SDK Orchestrator's post-turn maintenance
|
|
244
287
|
// (handlePostTurnContextMaintenance), which runs on every turn against the
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import type { CommandRegistry } from './command-registry.ts';
|
|
2
|
+
import { SESSION_SUBCOMMAND_ARG_HINTS } from './commands/session.ts';
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Per-command subcommand → argument-hint maps. `session`'s entries come
|
|
6
|
+
* straight from session.ts (SESSION_SUBCOMMAND_ARG_HINTS) — the same table
|
|
7
|
+
* the `/session` switch itself is built from — so the two can never drift
|
|
8
|
+
* apart; the others are hand-maintained here since they're small and their
|
|
9
|
+
* owning modules don't export an equivalent table. A drift test
|
|
10
|
+
* (src/test/input/command-args-hint.test.ts) checks every top-level key
|
|
11
|
+
* below resolves to a real registered command.
|
|
12
|
+
*/
|
|
3
13
|
const SUBCOMMAND_HINTS: Record<string, Record<string, string>> = {
|
|
4
|
-
session:
|
|
14
|
+
session: SESSION_SUBCOMMAND_ARG_HINTS,
|
|
5
15
|
template: { save: '<name>', use: '<name> [args]', edit: '<name>', delete: '<name>' },
|
|
6
16
|
secrets: { set: '<KEY> <value>', get: '<KEY>', delete: '<KEY>' },
|
|
7
17
|
permissions: { tool: '<name> allow|prompt|deny' },
|
|
8
18
|
config: { reset: '<key>' },
|
|
9
|
-
danger: {},
|
|
10
19
|
plugin: { enable: '<name>', disable: '<name>', reload: '' },
|
|
11
20
|
};
|
|
12
21
|
|
|
@@ -23,6 +23,7 @@ import type { FeatureFlagManager } from '@/runtime/index.ts';
|
|
|
23
23
|
import type { OperatorClient } from '@/runtime/index.ts';
|
|
24
24
|
import type { PeerClient } from '@/runtime/index.ts';
|
|
25
25
|
import type { DirectTransport } from '@/runtime/index.ts';
|
|
26
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
26
27
|
import type { VoiceProviderRegistry, VoiceService } from '@pellux/goodvibes-sdk/platform/voice';
|
|
27
28
|
import type { UiPlatformServices } from '../runtime/ui-services.ts';
|
|
28
29
|
import type {
|
|
@@ -80,6 +81,14 @@ export interface CommandUiActions {
|
|
|
80
81
|
kind: 'image' | 'text' | 'none';
|
|
81
82
|
marker?: string;
|
|
82
83
|
};
|
|
84
|
+
/**
|
|
85
|
+
* Read-only view of pending `[TEXT: pN, M lines]` fold markers currently
|
|
86
|
+
* sitting in the composer (registerPaste in handler-content-actions.ts) —
|
|
87
|
+
* backs /pastes, which previews a folded paste's actual content before the
|
|
88
|
+
* user submits it. A paste over the fold threshold is otherwise invisible
|
|
89
|
+
* until the LLM sees it.
|
|
90
|
+
*/
|
|
91
|
+
getPendingPastes?: () => ReadonlyMap<string, string>;
|
|
83
92
|
executeCommand?: (name: string, args: string[]) => Promise<boolean>;
|
|
84
93
|
/** Arm the one-key jump to a spawned CI fix-session (the surface attaches on the jump key; the user never retypes an id). */
|
|
85
94
|
armFixSessionAttach?: (fixSessionId: string) => void;
|
|
@@ -152,6 +161,8 @@ export interface CommandShellUiOpeners {
|
|
|
152
161
|
jumpToBookmark?: (key: string) => void;
|
|
153
162
|
scrollToLine?: (line: number) => void;
|
|
154
163
|
openHelpOverlay?: () => void;
|
|
164
|
+
/** Toggle the transcript search overlay (same action as Ctrl+F) — backs /find. */
|
|
165
|
+
openTranscriptSearch?: () => void;
|
|
155
166
|
openSelection?: (
|
|
156
167
|
title: string,
|
|
157
168
|
items: SelectionItem[],
|
|
@@ -270,6 +281,14 @@ export interface CommandProviderServices {
|
|
|
270
281
|
* runtime surfaces exported from runtime/shell-command-services.ts.
|
|
271
282
|
*/
|
|
272
283
|
export interface CommandWorkspaceUiServices {
|
|
284
|
+
/**
|
|
285
|
+
* The app's declare-once session-storage handle (runtime/services.ts). Every
|
|
286
|
+
* command that reads or writes session state — resume, save, the liveness
|
|
287
|
+
* markers, the rewind anchor sidecar — takes its paths from here instead of
|
|
288
|
+
* re-deriving a scope from shellPaths, so a command can never land on a
|
|
289
|
+
* different directory than the runtime that wrote the file.
|
|
290
|
+
*/
|
|
291
|
+
surface?: SessionSurface;
|
|
273
292
|
keybindingsManager?: KeybindingsManager;
|
|
274
293
|
fileUndoManager?: FileUndoManager;
|
|
275
294
|
workspaceCheckpointManager?: WorkspaceCheckpointManager;
|
|
@@ -19,8 +19,8 @@ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
|
|
|
19
19
|
// Formatting helpers
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
|
|
22
|
-
/** Drop the redundant 'wcp_' prefix and cap length for compact listing. Full ids (and any unambiguous prefix of one) are still accepted by /rewind. */
|
|
23
|
-
function shortId(id: string): string {
|
|
22
|
+
/** Drop the redundant 'wcp_' prefix and cap length for compact listing. Full ids (and any unambiguous prefix of one) are still accepted by /rewind. Exported: /rewind's no-anchors checkpoint fallback (rewind-runtime.ts) reuses this same formatting. */
|
|
23
|
+
export function shortId(id: string): string {
|
|
24
24
|
const stripped = id.startsWith('wcp_') ? id.slice(4) : id;
|
|
25
25
|
return stripped.length > 12 ? stripped.slice(0, 12) : stripped;
|
|
26
26
|
}
|
|
@@ -77,7 +77,7 @@ export function registerCheckpointRuntimeCommands(registry: CommandRegistry): vo
|
|
|
77
77
|
// them would cost one diff spawn per checkpoint (O(checkpoints)), which
|
|
78
78
|
// stops being cheap once there are more than a handful. /rewind <id>
|
|
79
79
|
// loads exactly one diff, so the exact file list is available there.
|
|
80
|
-
ctx.print('Use /rewind to preview and restore a completed turn (files and/or conversation) — it reuses these checkpoints for the files half.');
|
|
80
|
+
ctx.print('Use /rewind to preview and restore a completed turn (files and/or conversation) — it reuses these checkpoints for the files half. With no completed turns recorded this run (e.g. right after a restart), /rewind falls back to restoring one of these checkpoints directly, by number or id — files only, no conversation state.');
|
|
81
81
|
},
|
|
82
82
|
});
|
|
83
83
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { ServiceRegistry } from '@pellux/goodvibes-sdk/platform/config';
|
|
2
1
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
3
2
|
import { probeTermCaps } from '../../renderer/term-caps.ts';
|
|
4
3
|
import { evaluateSessionMaintenance, formatSessionMaintenanceLines } from '@/runtime/index.ts';
|
|
@@ -8,7 +7,6 @@ import { buildSetupReviewSnapshot } from './local-setup-review.ts';
|
|
|
8
7
|
import { buildProviderAccountSnapshot } from '@/runtime/index.ts';
|
|
9
8
|
import { getSettingsControlPlaneSnapshot } from '@/runtime/index.ts';
|
|
10
9
|
import { listPersistedWorktreeMeta, summarizeWorktreeOwnership } from '@/runtime/index.ts';
|
|
11
|
-
import { checkRecoveryFile, readLastSessionPointer } from '@/runtime/index.ts';
|
|
12
10
|
import {
|
|
13
11
|
openCommandPanel,
|
|
14
12
|
requireLocalUserAuthManager,
|
|
@@ -425,7 +423,7 @@ export function registerHealthRuntimeCommands(registry: CommandRegistry): void {
|
|
|
425
423
|
} else if (domain === 'continuity') {
|
|
426
424
|
lines.push(' domain: continuity');
|
|
427
425
|
lines.push(' /session list');
|
|
428
|
-
lines.push(' /
|
|
426
|
+
lines.push(' /resume <id>');
|
|
429
427
|
lines.push(' /session hotspots');
|
|
430
428
|
lines.push(' verify: /health continuity');
|
|
431
429
|
} else if (domain === 'maintenance') {
|
|
@@ -165,7 +165,7 @@ export function registerLocalRuntimeCommands(registry: CommandRegistry): void {
|
|
|
165
165
|
|
|
166
166
|
registry.register({
|
|
167
167
|
name: 'secrets',
|
|
168
|
-
description: '
|
|
168
|
+
description: 'Persistent secret storage: hierarchy-aware, external secret refs, providers (a quick one-off masked value instead: /secret)',
|
|
169
169
|
usage: 'set <KEY> <value> [--user|--project] [--secure|--plaintext] | link <KEY> <secret-ref> [--user|--project] [--secure|--plaintext] | get <KEY> | test <secret-ref> | providers | list | delete <KEY> [--user|--project] [--secure|--plaintext]',
|
|
170
170
|
argsHint: '<set|link|get|test|providers|list|delete> [KEY]',
|
|
171
171
|
async handler(args, ctx) {
|
|
@@ -39,12 +39,13 @@ import {
|
|
|
39
39
|
type RewindWorkspacePort,
|
|
40
40
|
} from '@pellux/goodvibes-sdk/platform/rewind';
|
|
41
41
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
42
|
-
import type { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
|
|
42
|
+
import type { WorkspaceCheckpoint, WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
|
|
43
43
|
import type { CommandContext, CommandRegistry } from '../command-registry.ts';
|
|
44
44
|
import { buildRewindReceiptBlock } from '../../core/rewind-receipt.ts';
|
|
45
45
|
import { getTurnAnchors, type TurnAnchor } from '../../core/rewind-turn-anchors.ts';
|
|
46
46
|
import { createConversationRewindPort, type ConversationRewindPort } from '../../runtime/conversation-rewind-port.ts';
|
|
47
47
|
import { requirePanelManager } from './runtime-services.ts';
|
|
48
|
+
import { shortId } from './checkpoint-runtime.ts';
|
|
48
49
|
|
|
49
50
|
export type RewindScope = SdkRewindScope;
|
|
50
51
|
const REWIND_SCOPES: readonly RewindScope[] = ['files', 'conversation', 'both'];
|
|
@@ -231,6 +232,127 @@ function parseScope(token: string | undefined): RewindScope | null {
|
|
|
231
232
|
return (REWIND_SCOPES as readonly string[]).includes(lower) ? (lower as RewindScope) : null;
|
|
232
233
|
}
|
|
233
234
|
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Checkpoint-only fallback — no completed turns recorded THIS run (e.g. right
|
|
237
|
+
// after a restart) means the message-anchored picker above has nothing to
|
|
238
|
+
// list, even though real workspace checkpoints exist on disk. Rather than
|
|
239
|
+
// dead-ending on "no completed turns" while /checkpoints and the boot notice
|
|
240
|
+
// both point back here, /rewind falls back to listing and restoring
|
|
241
|
+
// checkpoints directly — a FILES-ONLY restore (there is no turn anchor to
|
|
242
|
+
// join a conversation boundary against), reusing the same diff-panel
|
|
243
|
+
// preview/confirm idiom as the turn-anchored path above.
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
const CHECKPOINT_FALLBACK_NO_ANCHORS_MESSAGE = 'No completed turns recorded this run yet. /rewind targets turns completed in the current session run.';
|
|
247
|
+
|
|
248
|
+
function renderCheckpointFallbackList(checkpoints: readonly WorkspaceCheckpoint[]): string {
|
|
249
|
+
const lines = [
|
|
250
|
+
'No completed turns recorded this run yet — falling back to workspace checkpoints (FILES ONLY, no conversation state):',
|
|
251
|
+
...checkpoints.slice(0, 20).map((cp, i) => ` ${String(i + 1).padStart(2)}. ${shortId(cp.id).padEnd(12)} ${cp.kind.padEnd(9)} ${cp.label} (${formatAge(cp.createdAt)})`),
|
|
252
|
+
'Restore one: /rewind <n|id> — files only; conversation history is untouched.',
|
|
253
|
+
];
|
|
254
|
+
return lines.join('\n');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Resolve a user ref ('1'..'N' newest-first, or a checkpoint id / id prefix) to a checkpoint. */
|
|
258
|
+
function resolveCheckpointRef(checkpoints: readonly WorkspaceCheckpoint[], ref: string): WorkspaceCheckpoint | { error: string } {
|
|
259
|
+
if (/^\d+$/.test(ref)) {
|
|
260
|
+
const idx = Number(ref) - 1;
|
|
261
|
+
const cp = checkpoints[idx];
|
|
262
|
+
return cp ? cp : { error: `No checkpoint #${ref}. Run /rewind to list checkpoints (1–${checkpoints.length}).` };
|
|
263
|
+
}
|
|
264
|
+
const exact = checkpoints.find((cp) => cp.id === ref);
|
|
265
|
+
if (exact) return exact;
|
|
266
|
+
const prefix = checkpoints.filter((cp) => cp.id.startsWith(ref) || shortId(cp.id).startsWith(ref));
|
|
267
|
+
if (prefix.length === 1) return prefix[0]!;
|
|
268
|
+
if (prefix.length > 1) return { error: `Checkpoint ref "${ref}" is ambiguous (${prefix.length} matches). Use the list number instead.` };
|
|
269
|
+
return { error: `Unknown checkpoint ref "${ref}". Run /rewind to list checkpoints.` };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Handles bare `/rewind` and `/rewind <ref>` when this run has recorded no
|
|
274
|
+
* turn anchors at all. Returns true if it handled the invocation (always,
|
|
275
|
+
* once called), false is never returned — kept boolean-shaped only to mirror
|
|
276
|
+
* the calling convention at the call site.
|
|
277
|
+
*/
|
|
278
|
+
async function handleCheckpointOnlyRewind(args: string[], ctx: CommandContext): Promise<void> {
|
|
279
|
+
const mgr = ctx.workspace.workspaceCheckpointManager;
|
|
280
|
+
if (!mgr) {
|
|
281
|
+
ctx.print(CHECKPOINT_FALLBACK_NO_ANCHORS_MESSAGE);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
let checkpoints: WorkspaceCheckpoint[];
|
|
285
|
+
try {
|
|
286
|
+
checkpoints = await mgr.list();
|
|
287
|
+
} catch {
|
|
288
|
+
checkpoints = [];
|
|
289
|
+
}
|
|
290
|
+
if (checkpoints.length === 0) {
|
|
291
|
+
ctx.print(CHECKPOINT_FALLBACK_NO_ANCHORS_MESSAGE);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (args.length === 0) {
|
|
296
|
+
ctx.print(renderCheckpointFallbackList(checkpoints));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const resolved = resolveCheckpointRef(checkpoints, args[0]!);
|
|
301
|
+
if ('error' in resolved) {
|
|
302
|
+
ctx.print(resolved.error);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const checkpoint = resolved;
|
|
306
|
+
|
|
307
|
+
const { DiffPanel } = await import('../../panels/diff-panel.ts');
|
|
308
|
+
const pm = requirePanelManager(ctx);
|
|
309
|
+
let panel = pm.getAllOpen().find((p) => p.id === 'diff');
|
|
310
|
+
if (!panel) {
|
|
311
|
+
try { panel = pm.open('diff'); } catch { ctx.print('Could not open diff panel.'); return; }
|
|
312
|
+
}
|
|
313
|
+
pm.activateById('diff');
|
|
314
|
+
if (!pm.isVisible()) pm.show();
|
|
315
|
+
ctx.focusPanels?.();
|
|
316
|
+
const diffPanel = panel as InstanceType<typeof DiffPanel>;
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
const diff = await mgr.diff(checkpoint.id);
|
|
320
|
+
if (diff.unifiedDiff.trim()) diffPanel.loadRawDiff(diff.unifiedDiff);
|
|
321
|
+
else diffPanel.showDiff('(no file changes)', '@@ -0,0 +0,0 @@\n Working tree already matches this checkpoint.');
|
|
322
|
+
} catch {
|
|
323
|
+
diffPanel.showDiff('(preview unavailable)', '@@ -0,0 +0,0 @@\n Could not load the checkpoint diff.');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
diffPanel.confirmOverlay.arm({
|
|
327
|
+
id: checkpoint.id,
|
|
328
|
+
label: `${checkpoint.label} — restore FILES ONLY (no conversation state this run)`,
|
|
329
|
+
verb: 'Restore',
|
|
330
|
+
onConfirm: async () => {
|
|
331
|
+
try {
|
|
332
|
+
await mgr.restore(checkpoint.id, { safetyCheckpoint: true });
|
|
333
|
+
ctx.session.conversationManager.addTypedSystemMessage(
|
|
334
|
+
`[Rewind] Restored files from checkpoint ${shortId(checkpoint.id)} ("${checkpoint.label}"). Files only — no turn anchors exist this run, so conversation state is unchanged.`,
|
|
335
|
+
'operational',
|
|
336
|
+
);
|
|
337
|
+
pm.close('diff');
|
|
338
|
+
ctx.focusPrompt?.();
|
|
339
|
+
ctx.renderRequest();
|
|
340
|
+
} catch (err) {
|
|
341
|
+
ctx.print(`Checkpoint restore failed: ${summarizeError(err)}`);
|
|
342
|
+
}
|
|
343
|
+
},
|
|
344
|
+
onCancel: () => {
|
|
345
|
+
ctx.print('Restore cancelled — nothing changed.');
|
|
346
|
+
pm.close('diff');
|
|
347
|
+
ctx.focusPrompt?.();
|
|
348
|
+
ctx.renderRequest();
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
ctx.print(`Previewing checkpoint restore: "${checkpoint.label}" (FILES ONLY — no conversation state this run). Confirm in the diff panel: Enter/y to restore, n/Esc to cancel.`);
|
|
353
|
+
ctx.renderRequest();
|
|
354
|
+
}
|
|
355
|
+
|
|
234
356
|
// ---------------------------------------------------------------------------
|
|
235
357
|
// Command registration
|
|
236
358
|
// ---------------------------------------------------------------------------
|
|
@@ -244,6 +366,15 @@ export function registerRewindRuntimeCommands(registry: CommandRegistry): void {
|
|
|
244
366
|
handler: async (args, ctx: CommandContext) => {
|
|
245
367
|
const sessionId = ctx.session.runtime.sessionId;
|
|
246
368
|
|
|
369
|
+
// No turn anchors recorded this run (most commonly: right after a
|
|
370
|
+
// restart, before /session resume has restored any) — fall back to a
|
|
371
|
+
// files-only restore over the real workspace checkpoints instead of a
|
|
372
|
+
// dead end (see handleCheckpointOnlyRewind's header doc).
|
|
373
|
+
if (recentTurnsNewestFirst(sessionId).length === 0) {
|
|
374
|
+
await handleCheckpointOnlyRewind(args, ctx);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
247
378
|
if (args.length === 0) {
|
|
248
379
|
ctx.print(renderTurnList(sessionId));
|
|
249
380
|
return;
|
|
@@ -12,7 +12,7 @@ import type { CompactionContext, CompactionEvent } from '@pellux/goodvibes-sdk/p
|
|
|
12
12
|
import { recordCompactionQualityScore, scoreCompactionRun } from '../../renderer/compaction-quality.ts';
|
|
13
13
|
import type { CompactionQualityScore } from '../../renderer/compaction-quality.ts';
|
|
14
14
|
import type { UiReadModels } from '../../runtime/ui-read-models.ts';
|
|
15
|
-
import type { ShellPathService } from '@/runtime/index.ts';
|
|
15
|
+
import type { ShellPathService, SessionSurface } from '@/runtime/index.ts';
|
|
16
16
|
import type { EcosystemCatalogPathOptions } from '@/runtime/index.ts';
|
|
17
17
|
import type { PluginPathOptions } from '../../plugins/loader';
|
|
18
18
|
import type { DirectTransport } from '@/runtime/index.ts';
|
|
@@ -72,6 +72,16 @@ export function requireShellPaths(context: Pick<CommandContext, 'workspace'>): S
|
|
|
72
72
|
return requireContextValue(context.workspace.shellPaths, 'workspace.shellPaths');
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The runtime's declare-once session-storage handle. Commands that touch
|
|
77
|
+
* session state take their paths from here rather than rebuilding a scope out
|
|
78
|
+
* of shellPaths, so a command and the runtime that wrote the file always agree
|
|
79
|
+
* on where it lives.
|
|
80
|
+
*/
|
|
81
|
+
export function requireSurface(context: Pick<CommandContext, 'workspace'>): SessionSurface {
|
|
82
|
+
return requireContextValue(context.workspace.surface, 'workspace.surface');
|
|
83
|
+
}
|
|
84
|
+
|
|
75
85
|
export function requireEcosystemCatalogPaths(
|
|
76
86
|
context: CommandContext,
|
|
77
87
|
): EcosystemCatalogPathOptions {
|
|
@@ -21,7 +21,7 @@ function toEnvVarName(raw: string): string {
|
|
|
21
21
|
export function registerSecretRuntimeCommands(registry: CommandRegistry): void {
|
|
22
22
|
registry.register({
|
|
23
23
|
name: 'secret',
|
|
24
|
-
description: '
|
|
24
|
+
description: 'Quick one-off: type a value with masked input, stored as an env var for THIS session only (persistent storage + providers: /secrets)',
|
|
25
25
|
usage: '/secret <NAME>',
|
|
26
26
|
argsHint: '<NAME>',
|
|
27
27
|
handler(args, ctx) {
|
|
@@ -9,6 +9,7 @@ import { requireSessionManager, requireSessionMemoryStore, requireShellPaths } f
|
|
|
9
9
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
10
10
|
import { sessionCommand } from './session.ts';
|
|
11
11
|
import { undoLastRewind, redoLastRewind } from './rewind-runtime.ts';
|
|
12
|
+
import { filterUserFacingSessions } from './session-picker-filter.ts';
|
|
12
13
|
|
|
13
14
|
export function registerSessionContentCommands(registry: CommandRegistry): void {
|
|
14
15
|
registry.register({
|
|
@@ -113,6 +114,10 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
|
|
|
113
114
|
model: ctx.session.runtime.model,
|
|
114
115
|
provider: ctx.session.runtime.provider,
|
|
115
116
|
timestamp: Date.now(),
|
|
117
|
+
// `/save` is the user asking to keep this conversation. Stamped so
|
|
118
|
+
// the session-conversations retention sweep never expires it, unlike
|
|
119
|
+
// the automatic per-turn snapshot (turn-event-wiring.ts, 'auto').
|
|
120
|
+
saveSource: 'user' as const,
|
|
116
121
|
};
|
|
117
122
|
try {
|
|
118
123
|
const agentManager = ctx.ops.agentManager;
|
|
@@ -263,7 +268,10 @@ export function registerSessionContentCommands(registry: CommandRegistry): void
|
|
|
263
268
|
return;
|
|
264
269
|
}
|
|
265
270
|
const sessionManager = requireSessionManager(ctx);
|
|
266
|
-
|
|
271
|
+
// Excludes subagent-shaped transcripts (see session-picker-filter.ts) —
|
|
272
|
+
// an unfiltered list here drowns real user sessions in spawned-agent
|
|
273
|
+
// noise. A direct `/sessions resume agent-xxxxxxxx` still reaches them.
|
|
274
|
+
const sessions = filterUserFacingSessions(sessionManager.list());
|
|
267
275
|
if (ctx.openSelection) {
|
|
268
276
|
const deleteAction = new Map([['d', 'delete' as const]]);
|
|
269
277
|
const items: SelectionItem[] = sessions.length === 0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-picker-filter.ts — keep subagent transcripts out of user-facing
|
|
3
|
+
* session pickers.
|
|
4
|
+
*
|
|
5
|
+
* Subagents spawned during a session get their own SessionManager-persisted
|
|
6
|
+
* transcript, named `agent-<id>` (see the SDK's agent manager, which mints
|
|
7
|
+
* ids as `agent-${uuid.slice(0, 8)}`). Those files are real, loadable
|
|
8
|
+
* sessions — useful for forensic/debugging access — but they are not
|
|
9
|
+
* sessions a user ever started, and in a workspace with many spawned agents
|
|
10
|
+
* they drown the handful of sessions the user actually wants to pick from
|
|
11
|
+
* in `/resume` and `/sessions`.
|
|
12
|
+
*
|
|
13
|
+
* Both pickers filter agent-shaped entries out of the LIST/BROWSE view only.
|
|
14
|
+
* Direct resume by exact id/name (`/resume agent-xxxxxxxx`, `/session resume
|
|
15
|
+
* agent-xxxxxxxx`) is untouched and still reaches them — this only affects
|
|
16
|
+
* what shows up when the id isn't already known.
|
|
17
|
+
*/
|
|
18
|
+
import type { SessionInfo } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
19
|
+
|
|
20
|
+
/** Whether a saved session's name follows the subagent-transcript naming convention. */
|
|
21
|
+
export function isAgentSessionName(name: string): boolean {
|
|
22
|
+
return name.startsWith('agent-');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Filter a session list down to user-facing (non-subagent) entries. */
|
|
26
|
+
export function filterUserFacingSessions<T extends Pick<SessionInfo, 'name'>>(sessions: readonly T[]): T[] {
|
|
27
|
+
return sessions.filter((s) => !isAgentSessionName(s.name));
|
|
28
|
+
}
|