@pellux/goodvibes-tui 1.19.5 → 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 +12 -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/bookmark-navigation.ts +44 -0
- package/src/core/conversation-line-cache.ts +87 -19
- package/src/core/conversation-rendering.ts +186 -28
- package/src/core/conversation-tool-groups.ts +152 -0
- package/src/core/conversation-types.ts +30 -3
- package/src/core/conversation.ts +17 -0
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +62 -7
- 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 +19 -2
- 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 +55 -56
- 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 -1
- 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 +26 -21
- 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 +17 -98
- 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
|
@@ -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') {
|
|
@@ -34,14 +34,31 @@ function toggleBlocks(typeFilter: string, collapsed: boolean, ctx: CommandContex
|
|
|
34
34
|
let count = 0;
|
|
35
35
|
for (let i = 0; i < blockRegistry.length; i++) {
|
|
36
36
|
const block = blockRegistry[i];
|
|
37
|
+
// A folded tool-result group (type 'tool_group', see conversation-tool-groups.ts)
|
|
38
|
+
// is a run of tool results under one header, so '/expand tool'/'collapse tool'
|
|
39
|
+
// treats it the same as a plain 'tool' block — toggling the header's collapse
|
|
40
|
+
// key expands/collapses every member underneath it.
|
|
37
41
|
const matchesType = typeFilter === 'all'
|
|
38
|
-
|| (typeFilter === 'tool' && block.type === 'tool')
|
|
42
|
+
|| (typeFilter === 'tool' && (block.type === 'tool' || block.type === 'tool_group'))
|
|
39
43
|
|| (typeFilter === 'code' && block.type === 'code')
|
|
40
44
|
|| (typeFilter === 'thinking' && block.type === 'thinking');
|
|
41
45
|
if (!matchesType) continue;
|
|
42
46
|
const isCurrentlyCollapsed = ctx.session.conversationManager.isCollapsed(i);
|
|
43
47
|
if (collapsed ? !isCurrentlyCollapsed : isCurrentlyCollapsed) {
|
|
44
48
|
ctx.session.conversationManager.toggleCollapseAtLine(block.startLine);
|
|
49
|
+
// Expanding a folded tool-result group also expands every member's own
|
|
50
|
+
// collapse key in the same pass. A folded member pushes no BlockMeta of
|
|
51
|
+
// its own while the group is collapsed, so it never surfaces from this
|
|
52
|
+
// loop to be toggled individually — without this, '/expand tool' only
|
|
53
|
+
// opens the group header and each member still renders at whatever its
|
|
54
|
+
// own default collapse state is, needing a second '/expand tool' pass.
|
|
55
|
+
// '/collapse tool' needs no matching step: collapsing the group hides
|
|
56
|
+
// every member regardless of its own key.
|
|
57
|
+
if (!collapsed && block.type === 'tool_group' && block.groupMemberIndexes) {
|
|
58
|
+
for (const memberIdx of block.groupMemberIndexes) {
|
|
59
|
+
ctx.session.conversationManager.setCollapsed(`msg_${memberIdx}`, false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
45
62
|
count++;
|
|
46
63
|
}
|
|
47
64
|
}
|
|
@@ -148,7 +165,7 @@ export function registerLocalRuntimeCommands(registry: CommandRegistry): void {
|
|
|
148
165
|
|
|
149
166
|
registry.register({
|
|
150
167
|
name: 'secrets',
|
|
151
|
-
description: '
|
|
168
|
+
description: 'Persistent secret storage: hierarchy-aware, external secret refs, providers (a quick one-off masked value instead: /secret)',
|
|
152
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]',
|
|
153
170
|
argsHint: '<set|link|get|test|providers|list|delete> [KEY]',
|
|
154
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
|
+
}
|
|
@@ -6,9 +6,9 @@ import type { TranscriptEventKind } from '@pellux/goodvibes-sdk/platform/core';
|
|
|
6
6
|
import type { ConversationTitleSource } from '../../core/conversation';
|
|
7
7
|
import type { SessionReturnContextSummary } from '@/runtime/index.ts';
|
|
8
8
|
import { formatReturnContextForDisplay, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
|
|
9
|
-
import { requirePanelManager, requireProviderApi, requireSessionManager,
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
9
|
+
import { requirePanelManager, requireProviderApi, requireSessionManager, requireSurface } from './runtime-services.ts';
|
|
10
|
+
import { resumeSessionCore, reopenPanelsWithModalSkip, DEFAULT_PANEL_REOPEN_LIMIT } from '../../core/session-resume-core.ts';
|
|
11
|
+
import { checkSessionLiveness } from '../../runtime/session-liveness-marker.ts';
|
|
12
12
|
import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
|
|
13
13
|
|
|
14
14
|
function parseTranscriptKind(raw: string | undefined): TranscriptEventKind | 'all' {
|
|
@@ -83,32 +83,20 @@ function buildTranscriptReviewLines(
|
|
|
83
83
|
// Exported (test-only concern) so the MIGRATE-TO-MODAL redirect-skip honesty
|
|
84
84
|
// (W6 review, finding 3: saved-layout restore with 'sessions' must not lie)
|
|
85
85
|
// can be unit-tested directly instead of through a full /resume harness.
|
|
86
|
+
// Delegates to the shared reopenPanelsWithModalSkip (core/session-resume-core.ts)
|
|
87
|
+
// — the same routine resumeSessionCore uses — so this standalone entry point
|
|
88
|
+
// and the canonical resume sequence can never diverge on panel-reopen behavior.
|
|
86
89
|
export function reopenPanelsFromReturnContext(ctx: CommandContext, summary: SessionReturnContextSummary | undefined): string[] {
|
|
87
90
|
if (!summary?.openPanels || summary.openPanels.length === 0) return [];
|
|
88
91
|
const panelManager = requirePanelManager(ctx);
|
|
89
|
-
const reopened
|
|
90
|
-
const movedToModal: string[] = [];
|
|
91
|
-
for (const panelId of summary.openPanels.slice(0, 4)) {
|
|
92
|
-
// (the purge): a MIGRATE-TO-MODAL id has no panel to restore — a modal
|
|
93
|
-
// is not part of the saved panel layout. Skip it (don't pop a modal
|
|
94
|
-
// mid-resume) and note it once, rather than firing openModal + revealing an
|
|
95
|
-
// empty workspace during resume.
|
|
96
|
-
if (panelManager.getModalRedirect(panelId) !== undefined) {
|
|
97
|
-
movedToModal.push(panelId);
|
|
98
|
-
continue;
|
|
99
|
-
}
|
|
100
|
-
try {
|
|
101
|
-
panelManager.open(panelId);
|
|
102
|
-
reopened.push(panelId);
|
|
103
|
-
} catch {
|
|
104
|
-
// Ignore unknown or currently unavailable panel ids during resume.
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
if (reopened.length > 0) panelManager.show();
|
|
92
|
+
const { reopened, movedToModal, notReopened } = reopenPanelsWithModalSkip(panelManager, summary.openPanels, DEFAULT_PANEL_REOPEN_LIMIT);
|
|
108
93
|
if (movedToModal.length > 0) {
|
|
109
94
|
ctx.print(`Note: ${movedToModal.join(', ')} moved to a modal — reopen via its command instead of as a panel.`);
|
|
110
95
|
}
|
|
111
|
-
|
|
96
|
+
if (notReopened.length > 0) {
|
|
97
|
+
ctx.print(` …and ${notReopened.length} more not reopened (/panels to open)`);
|
|
98
|
+
}
|
|
99
|
+
return [...reopened];
|
|
112
100
|
}
|
|
113
101
|
|
|
114
102
|
function printSessionExport(
|
|
@@ -198,7 +186,9 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
|
|
|
198
186
|
session.returnContext.activeTasks ? `active=${session.returnContext.activeTasks}` : null,
|
|
199
187
|
session.returnContext.blockedTasks ? `blocked=${session.returnContext.blockedTasks}` : null,
|
|
200
188
|
session.returnContext.pendingApprovals ? `approvals=${session.returnContext.pendingApprovals}` : null,
|
|
201
|
-
session.returnContext.openPanels?.length
|
|
189
|
+
session.returnContext.openPanels?.length
|
|
190
|
+
? `panels=${session.returnContext.openPanels.slice(0, 3).join(',')}${session.returnContext.openPanels.length > 3 ? ` (+${session.returnContext.openPanels.length - 3} more)` : ''}`
|
|
191
|
+
: null,
|
|
202
192
|
].filter(Boolean).join(' ');
|
|
203
193
|
if (posture) lines.push(` posture: ${posture}`);
|
|
204
194
|
}
|
|
@@ -222,6 +212,10 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
|
|
|
222
212
|
model: ctx.session.runtime.model,
|
|
223
213
|
provider: ctx.session.runtime.provider,
|
|
224
214
|
timestamp: Date.now(),
|
|
215
|
+
// Naming a session is an act of curation, so the file this
|
|
216
|
+
// materializes for rename to act on is a user save, not machinery —
|
|
217
|
+
// the retention sweep must not expire something the user named.
|
|
218
|
+
saveSource: 'user',
|
|
225
219
|
});
|
|
226
220
|
}
|
|
227
221
|
sm.rename(ctx.session.runtime.sessionId, newName);
|
|
@@ -235,9 +229,13 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
|
|
|
235
229
|
}
|
|
236
230
|
|
|
237
231
|
if (sub === 'resume') {
|
|
238
|
-
|
|
232
|
+
// '--force' bypasses the multi-instance liveness confirm below — never a
|
|
233
|
+
// session-id/name token itself (session ids never start with '--').
|
|
234
|
+
const rawArgs = args.slice(1);
|
|
235
|
+
const force = rawArgs.includes('--force');
|
|
236
|
+
const target = rawArgs.filter((a) => a !== '--force').join(' ').trim();
|
|
239
237
|
if (!target) {
|
|
240
|
-
ctx.print('Usage: /session resume <session-id-or-name>');
|
|
238
|
+
ctx.print('Usage: /session resume <session-id-or-name> [--force]');
|
|
241
239
|
return true;
|
|
242
240
|
}
|
|
243
241
|
const sessions = sm.list();
|
|
@@ -250,56 +248,36 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
|
|
|
250
248
|
ctx.print(`Session not found: ${target}\nUse /session list to see available sessions.`);
|
|
251
249
|
return true;
|
|
252
250
|
}
|
|
253
|
-
try {
|
|
254
|
-
const { meta, messages } = sm.load(found.name);
|
|
255
|
-
const providerApi = requireProviderApi(ctx);
|
|
256
|
-
ctx.session.conversationManager.resetAll();
|
|
257
|
-
ctx.session.conversationManager.fromJSON({ messages: messages as never[], title: meta.title, titleSource: meta.titleSource });
|
|
258
|
-
ctx.session.conversationManager.rebuildHistory();
|
|
259
|
-
ctx.session.runtime.sessionId = found.name;
|
|
260
251
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
252
|
+
// Multi-instance safety: warn (and require --force) when this session
|
|
253
|
+
// looks open in another still-running terminal right now — resuming it
|
|
254
|
+
// here would fork its live state out from under that other instance.
|
|
255
|
+
// Best-effort: a missing/stale marker (or this process's OWN marker,
|
|
256
|
+
// e.g. re-resuming the session already active here) never blocks anything.
|
|
257
|
+
const liveness = checkSessionLiveness(requireSurface(ctx), found.name);
|
|
258
|
+
if (liveness.live && liveness.pid !== process.pid && !force) {
|
|
259
|
+
ctx.print(
|
|
260
|
+
`Session appears open in another terminal (pid ${liveness.pid}) — resuming will fork its live state.\n` +
|
|
261
|
+
`Re-run \`/session resume ${found.name} --force\` to proceed anyway.`,
|
|
262
|
+
);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
266
265
|
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
snapshotTimestamp: meta.timestamp,
|
|
266
|
+
try {
|
|
267
|
+
const providerApi = requireProviderApi(ctx);
|
|
268
|
+
const outcome = await resumeSessionCore(found.name, {
|
|
269
|
+
sessionManager: sm,
|
|
272
270
|
conversation: ctx.session.conversationManager,
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
provider: meta.provider,
|
|
279
|
-
timestamp: Date.now(),
|
|
280
|
-
titleSource: meta.titleSource,
|
|
281
|
-
returnContext: meta.returnContext,
|
|
282
|
-
});
|
|
283
|
-
},
|
|
271
|
+
runtime: ctx.session.runtime,
|
|
272
|
+
surface: requireSurface(ctx),
|
|
273
|
+
panelManager: requirePanelManager(ctx),
|
|
274
|
+
selectModel: (model) => providerApi.selectModel(model),
|
|
275
|
+
hydrateSessionUsage: ctx.session.hydrateSessionUsage,
|
|
284
276
|
});
|
|
285
|
-
|
|
286
|
-
// history now, before ctx.renderRequest() below.
|
|
287
|
-
ctx.session.hydrateSessionUsage?.();
|
|
277
|
+
const { meta, journalReplay, restoredAnchorCount, resumedMessageCount, panels } = outcome;
|
|
288
278
|
|
|
289
|
-
if (meta.model) {
|
|
290
|
-
try {
|
|
291
|
-
const selected = await providerApi.selectModel(meta.model);
|
|
292
|
-
ctx.session.runtime.model = selected.registryKey;
|
|
293
|
-
ctx.session.runtime.provider = selected.providerId;
|
|
294
|
-
} catch {
|
|
295
|
-
ctx.session.runtime.model = meta.model;
|
|
296
|
-
// model may not exist locally
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
if (meta.provider) ctx.session.runtime.provider = meta.provider;
|
|
300
279
|
ctx.renderRequest();
|
|
301
|
-
|
|
302
|
-
ctx.print(`Resumed session: ${found.name}\n Name: ${meta.title || '(untitled)'}\n Messages: ${resumedMsgCount}\n Model: ${meta.model || ctx.session.runtime.model}`);
|
|
280
|
+
ctx.print(`Resumed session: ${found.name}\n Name: ${meta.title || '(untitled)'}\n Messages: ${resumedMessageCount}\n Model: ${meta.model || ctx.session.runtime.model}`);
|
|
303
281
|
if (restoredAnchorCount > 0) {
|
|
304
282
|
ctx.print(` Rewind: restored ${restoredAnchorCount} turn anchor(s) — /rewind is available for this resumed session.`);
|
|
305
283
|
}
|
|
@@ -311,14 +289,19 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
|
|
|
311
289
|
} else if (journalReplay.hadCorruptTail) {
|
|
312
290
|
ctx.print(' [Recovery] Journal tail was partially corrupt (quarantined). Replay stopped at last good record.');
|
|
313
291
|
}
|
|
314
|
-
|
|
292
|
+
if (panels.movedToModal.length > 0) {
|
|
293
|
+
ctx.print(`Note: ${panels.movedToModal.join(', ')} moved to a modal — reopen via its command instead of as a panel.`);
|
|
294
|
+
}
|
|
295
|
+
if (panels.notReopened.length > 0) {
|
|
296
|
+
ctx.print(` …and ${panels.notReopened.length} more not reopened (/panels to open)`);
|
|
297
|
+
}
|
|
315
298
|
const returnContextMode = getReturnContextMode(ctx.platform.configManager);
|
|
316
299
|
if (returnContextMode !== 'off' && meta.returnContext) {
|
|
317
300
|
for (const line of formatReturnContextForDisplay(meta.returnContext)) {
|
|
318
301
|
ctx.print(` ${line}`);
|
|
319
302
|
}
|
|
320
|
-
if (
|
|
321
|
-
ctx.print(` Reopened panels: ${
|
|
303
|
+
if (panels.reopened.length > 0) {
|
|
304
|
+
ctx.print(` Reopened panels: ${panels.reopened.join(', ')}`);
|
|
322
305
|
}
|
|
323
306
|
if ((meta.returnContext.remoteRunners?.length ?? 0) > 0) {
|
|
324
307
|
ctx.print(` Remote re-entry: /remote recover ${meta.returnContext.remoteRunners![0]}`);
|
|
@@ -354,6 +337,8 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
|
|
|
354
337
|
timestamp: Date.now(),
|
|
355
338
|
titleSource: exportData.titleSource,
|
|
356
339
|
returnContext: exportData.returnContext,
|
|
340
|
+
// The operator asked for this fork by name; it is not turn machinery.
|
|
341
|
+
saveSource: 'user',
|
|
357
342
|
};
|
|
358
343
|
try {
|
|
359
344
|
sm.save(newId, messages, meta);
|
|
@@ -378,6 +363,10 @@ export async function handleSessionWorkflowCommand(args: string[], ctx: CommandC
|
|
|
378
363
|
timestamp: Date.now(),
|
|
379
364
|
titleSource: exportData.titleSource,
|
|
380
365
|
returnContext: exportData.returnContext,
|
|
366
|
+
// The one unambiguous "keep this" act in the app. Marked so the
|
|
367
|
+
// session-conversations retention sweep never expires it, unlike the
|
|
368
|
+
// automatic per-turn snapshot (turn-event-wiring.ts, saveSource 'auto').
|
|
369
|
+
saveSource: 'user',
|
|
381
370
|
};
|
|
382
371
|
try {
|
|
383
372
|
const { filePath, sanitizedName } = sm.save(rawName, messages, meta);
|