@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { UIFactory } from '../renderer/ui-factory.ts';
|
|
2
2
|
import { renderMarkdownTracked } from '../renderer/markdown.ts';
|
|
3
|
-
import {
|
|
3
|
+
import { isDiffContent, renderExpandedToolResultLines } from '../renderer/tool-result-expanded-lines.ts';
|
|
4
4
|
import { activeTheme, activeUiTones } from '../renderer/theme.ts';
|
|
5
5
|
import { renderToolCallBlock } from '../renderer/tool-call.ts';
|
|
6
6
|
import { summarizeToolResult } from '../renderer/tool-result-summary.ts';
|
|
@@ -207,8 +207,34 @@ export function renderConversationAssistantMessage(
|
|
|
207
207
|
const thinkingStartLine = context.history.getLineCount();
|
|
208
208
|
const thinkingBlockIdx = context.blockRegistry.length;
|
|
209
209
|
const thinkingCollapseKey = `msg_${msgIdx}_thinking`;
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
// Collapsed by default, like every other collapsible block — Tab (or the
|
|
211
|
+
// block-actions menu) expands it via the same collapseKey the BlockMeta
|
|
212
|
+
// below registers. Previously this key was registered but never
|
|
213
|
+
// consulted: the toggle existed but did nothing.
|
|
214
|
+
const isThinkingCollapsed = context.collapseState.has(thinkingCollapseKey)
|
|
215
|
+
? context.collapseState.get(thinkingCollapseKey)!
|
|
216
|
+
: true;
|
|
217
|
+
if (!context.collapseState.has(thinkingCollapseKey)) {
|
|
218
|
+
context.collapseState.set(thinkingCollapseKey, true);
|
|
219
|
+
}
|
|
220
|
+
if (isThinkingCollapsed) {
|
|
221
|
+
const thinkingLineCount = message.reasoningContent.split('\n').length;
|
|
222
|
+
const rendered = renderConversationCollapsedFragment(
|
|
223
|
+
`thinking · ${thinkingLineCount} line${thinkingLineCount === 1 ? '' : 's'}`,
|
|
224
|
+
width,
|
|
225
|
+
{
|
|
226
|
+
prefix: ' ▌ ',
|
|
227
|
+
prefixFg: T.reasoningAccent,
|
|
228
|
+
text: '244',
|
|
229
|
+
bodyBg: T.collapsedBodyBg,
|
|
230
|
+
dim: true,
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
context.history.addLines(rendered);
|
|
234
|
+
} else {
|
|
235
|
+
const thinkingLines = renderThinkingBlock(message.reasoningContent, width);
|
|
236
|
+
context.history.addLines(thinkingLines);
|
|
237
|
+
}
|
|
212
238
|
context.history.addLine(createEmptyLine(width));
|
|
213
239
|
const thinkingRenderedLines = context.history.getLineCount() - thinkingStartLine;
|
|
214
240
|
context.blockRegistry.push({
|
|
@@ -340,6 +366,30 @@ export function isFoldedGroupMember(
|
|
|
340
366
|
return membership !== undefined && !membership.isFirst && (collapseState.get(membership.groupKey) ?? true);
|
|
341
367
|
}
|
|
342
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Dedupe a group's per-member tool names into a compact, honest summary —
|
|
371
|
+
* "read×3 exec write" rather than either a bare count or a full repeated
|
|
372
|
+
* list. Truncates with an honest "…+N more" tail rather than silently
|
|
373
|
+
* dropping names once the list gets long.
|
|
374
|
+
*/
|
|
375
|
+
function summarizeToolNames(names: readonly string[]): string {
|
|
376
|
+
const counts = new Map<string, number>();
|
|
377
|
+
const order: string[] = [];
|
|
378
|
+
for (const name of names) {
|
|
379
|
+
if (!counts.has(name)) {
|
|
380
|
+
counts.set(name, 0);
|
|
381
|
+
order.push(name);
|
|
382
|
+
}
|
|
383
|
+
counts.set(name, counts.get(name)! + 1);
|
|
384
|
+
}
|
|
385
|
+
const MAX_SHOWN = 4;
|
|
386
|
+
const parts = order.map((name) => (counts.get(name)! > 1 ? `${name}×${counts.get(name)}` : name));
|
|
387
|
+
if (parts.length > MAX_SHOWN) {
|
|
388
|
+
return `${parts.slice(0, MAX_SHOWN).join(' ')} …+${parts.length - MAX_SHOWN} more`;
|
|
389
|
+
}
|
|
390
|
+
return parts.join(' ');
|
|
391
|
+
}
|
|
392
|
+
|
|
343
393
|
/**
|
|
344
394
|
* Render the synthetic header line for a folded run of >=2 tool-result
|
|
345
395
|
* messages: one line + one BlockMeta (type 'tool_group'), shared by every
|
|
@@ -363,6 +413,7 @@ function renderToolGroupHeader(
|
|
|
363
413
|
|
|
364
414
|
const blockIdx = context.blockRegistry.length;
|
|
365
415
|
const startLine = context.history.getLineCount();
|
|
416
|
+
const toolNamesSummary = summarizeToolNames(membership.toolNames);
|
|
366
417
|
context.history.addLine(renderConversationEventLine(width, {
|
|
367
418
|
marker: GLYPHS.surface.altCursor,
|
|
368
419
|
markerFg: T.toolAccent,
|
|
@@ -371,6 +422,7 @@ function renderToolGroupHeader(
|
|
|
371
422
|
detailFg: '244',
|
|
372
423
|
}, [
|
|
373
424
|
{ text: ` ${membership.toolCount} tool${membership.toolCount === 1 ? '' : 's'} `, fg: T.toolAccent },
|
|
425
|
+
...(toolNamesSummary ? [{ text: ` ${toolNamesSummary} `, fg: T.toolNameFg }] : []),
|
|
374
426
|
{ text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${membership.totalLines} line${membership.totalLines === 1 ? '' : 's'} `, fg: '244', dim: true },
|
|
375
427
|
]));
|
|
376
428
|
|
|
@@ -380,7 +432,7 @@ function renderToolGroupHeader(
|
|
|
380
432
|
type: 'tool_group',
|
|
381
433
|
startLine,
|
|
382
434
|
lineCount: 1,
|
|
383
|
-
rawContent: `${membership.toolCount} tool result${membership.toolCount === 1 ? '' : 's'} folded (${membership.totalLines} line${membership.totalLines === 1 ? '' : 's'} total)`,
|
|
435
|
+
rawContent: `${membership.toolCount} tool result${membership.toolCount === 1 ? '' : 's'} folded (${toolNamesSummary}, ${membership.totalLines} line${membership.totalLines === 1 ? '' : 's'} total)`,
|
|
384
436
|
groupMemberIndexes: membership.memberIndexes,
|
|
385
437
|
});
|
|
386
438
|
|
|
@@ -409,14 +461,18 @@ export function renderConversationToolMessage(
|
|
|
409
461
|
const blockIdx = context.blockRegistry.length;
|
|
410
462
|
const startLine = context.history.getLineCount();
|
|
411
463
|
const contentLines = message.content.split('\n');
|
|
412
|
-
const
|
|
413
|
-
const hasDiffHeader = contentLines.some((l) => l.startsWith('--- ')) && contentLines.some((l) => l.startsWith('+++ '));
|
|
414
|
-
const hasHunk = contentLines.some((l) => l.startsWith('@@ '));
|
|
415
|
-
const isDiff = hasDiffHeader && hasHunk;
|
|
464
|
+
const isDiff = isDiffContent(message.content);
|
|
416
465
|
const blockType: 'diff' | 'tool' = isDiff ? 'diff' : 'tool';
|
|
417
466
|
// Parsed once, ahead of the collapse check, so it's available for the
|
|
418
467
|
// block-registry meta merge below regardless of collapsed/expanded state.
|
|
419
468
|
const diffParse = isDiff ? parseDiffForApply(message.content) : undefined;
|
|
469
|
+
// The EXPANDED render, computed unconditionally (even while collapsed) so
|
|
470
|
+
// the header's "N lines" badge always names what Tab would actually reveal
|
|
471
|
+
// — a raw JSON blob that pretty-prints to 50 lines must say 50, not 1, even
|
|
472
|
+
// while it's still folded. Reused below as the actual body when expanded,
|
|
473
|
+
// so this is never rendered twice.
|
|
474
|
+
const expandedLines = renderExpandedToolResultLines(message.content, width);
|
|
475
|
+
const lineCount = expandedLines.length;
|
|
420
476
|
|
|
421
477
|
// Human one-line summary for tool results (write/read/exec/edit): shown as the
|
|
422
478
|
// collapsed line so the transcript reads "wrote foo.txt (532 B)" instead of a
|
|
@@ -485,22 +541,11 @@ export function renderConversationToolMessage(
|
|
|
485
541
|
dim: true,
|
|
486
542
|
});
|
|
487
543
|
context.history.addLines(rendered);
|
|
488
|
-
} else if (isDiff) {
|
|
489
|
-
// No filename banner: the diff text's own --- / +++ headers already
|
|
490
|
-
// identify the file (matches the pre-v0.9.6 call site in tool-call.ts).
|
|
491
|
-
context.history.addLines(renderDiffView(message.content, width));
|
|
492
544
|
} else {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
const parsed = JSON.parse(contentToRender);
|
|
498
|
-
contentToRender = `\`\`\`json\n${JSON.stringify(parsed, null, 2)}\n\`\`\``;
|
|
499
|
-
} catch {
|
|
500
|
-
// Leave invalid JSON as-is.
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
context.history.addLines(renderMarkdownTracked(contentToRender, width).lines);
|
|
545
|
+
// Diff or plain result — either way this is exactly `expandedLines`,
|
|
546
|
+
// already computed above (and already what the header's line count is
|
|
547
|
+
// honest about), so there is nothing left to render here.
|
|
548
|
+
context.history.addLines(expandedLines);
|
|
504
549
|
}
|
|
505
550
|
|
|
506
551
|
const renderedLineCount = context.history.getLineCount() - startLine;
|
|
@@ -511,6 +556,7 @@ export function renderConversationToolMessage(
|
|
|
511
556
|
startLine,
|
|
512
557
|
lineCount: renderedLineCount,
|
|
513
558
|
rawContent: message.content,
|
|
559
|
+
toolName: message.toolName,
|
|
514
560
|
};
|
|
515
561
|
|
|
516
562
|
if (isDiff && diffParse) {
|
|
@@ -545,7 +591,7 @@ export function appendConversationMessages(
|
|
|
545
591
|
// unless the caller already supplied membership (the line-cache path
|
|
546
592
|
// computes it once per renderInto call and threads it through).
|
|
547
593
|
const toolGroupMembership = renderContext.toolGroupMembership
|
|
548
|
-
?? computeToolGroupMembership(messages, msgIndexOffset);
|
|
594
|
+
?? computeToolGroupMembership(messages, msgIndexOffset, width);
|
|
549
595
|
const groupedContext: ConversationRenderContext = renderContext.toolGroupMembership !== undefined
|
|
550
596
|
? renderContext
|
|
551
597
|
: { ...renderContext, toolGroupMembership };
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
24
|
+
import { renderExpandedToolResultLines } from '../renderer/tool-result-expanded-lines.ts';
|
|
24
25
|
|
|
25
26
|
type Message = ConversationMessageSnapshot;
|
|
26
27
|
|
|
@@ -44,10 +45,15 @@ export interface ToolGroupMembership {
|
|
|
44
45
|
readonly isFirst: boolean;
|
|
45
46
|
/** Matched tool-result messages in the group (the header's "N tools"). */
|
|
46
47
|
readonly toolCount: number;
|
|
47
|
-
/** Sum of each member's
|
|
48
|
-
* the same count each member
|
|
49
|
-
* the group total
|
|
48
|
+
/** Sum of each member's EXPANDED render line count (the header's "N lines") —
|
|
49
|
+
* the same post-render count each member's own "N lines" badge shows, so
|
|
50
|
+
* the group total matches what expanding every member actually reveals,
|
|
51
|
+
* not a raw-content estimate (a folded JSON result can pretty-print to far
|
|
52
|
+
* more lines than its raw content has). */
|
|
50
53
|
readonly totalLines: number;
|
|
54
|
+
/** Each member's tool name, in order (falls back to 'tool' when a result
|
|
55
|
+
* carries no name) — the header dedupes these into e.g. "read×3 exec". */
|
|
56
|
+
readonly toolNames: readonly string[];
|
|
51
57
|
/**
|
|
52
58
|
* Absolute indexes of every tool-result message in the group, in order —
|
|
53
59
|
* the same list for every member. Carried onto the group's BlockMeta so a
|
|
@@ -58,10 +64,6 @@ export interface ToolGroupMembership {
|
|
|
58
64
|
readonly memberIndexes: readonly number[];
|
|
59
65
|
}
|
|
60
66
|
|
|
61
|
-
function rawLineCount(content: string): number {
|
|
62
|
-
return content.split('\n').length;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
67
|
/**
|
|
66
68
|
* Scan `messages` for assistant-message runs of >=2 consecutive matching
|
|
67
69
|
* tool-result messages. A run breaks on the first message that isn't a
|
|
@@ -103,19 +105,28 @@ export function detectToolGroups(
|
|
|
103
105
|
return groups;
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
/** Build the per-tool-message membership lookup used by the renderer/cache.
|
|
108
|
+
/** Build the per-tool-message membership lookup used by the renderer/cache.
|
|
109
|
+
* `width` must match the width the transcript is actually rendered at — it
|
|
110
|
+
* feeds the same expanded-render line count each member's own badge uses
|
|
111
|
+
* (see renderExpandedToolResultLines), so the group total is never computed
|
|
112
|
+
* against a different width than what the member badges show. */
|
|
107
113
|
export function buildToolGroupMembership(
|
|
108
114
|
groups: readonly ToolGroup[],
|
|
109
115
|
messages: readonly Message[],
|
|
110
116
|
indexOffset: number,
|
|
117
|
+
width: number,
|
|
111
118
|
): ReadonlyMap<number, ToolGroupMembership> {
|
|
112
119
|
const membership = new Map<number, ToolGroupMembership>();
|
|
113
120
|
for (const group of groups) {
|
|
114
121
|
const groupKey = `group_${group.assistantIdx}`;
|
|
115
122
|
let totalLines = 0;
|
|
123
|
+
const toolNames: string[] = [];
|
|
116
124
|
for (const absIdx of group.toolMessageIndexes) {
|
|
117
125
|
const message = messages[absIdx - indexOffset];
|
|
118
|
-
if (message && message.role === 'tool')
|
|
126
|
+
if (message && message.role === 'tool') {
|
|
127
|
+
totalLines += renderExpandedToolResultLines(message.content, width).length;
|
|
128
|
+
toolNames.push(message.toolName ?? 'tool');
|
|
129
|
+
}
|
|
119
130
|
}
|
|
120
131
|
group.toolMessageIndexes.forEach((absIdx, idx) => {
|
|
121
132
|
membership.set(absIdx, {
|
|
@@ -123,6 +134,7 @@ export function buildToolGroupMembership(
|
|
|
123
134
|
isFirst: idx === 0,
|
|
124
135
|
toolCount: group.toolMessageIndexes.length,
|
|
125
136
|
totalLines,
|
|
137
|
+
toolNames,
|
|
126
138
|
memberIndexes: group.toolMessageIndexes,
|
|
127
139
|
});
|
|
128
140
|
});
|
|
@@ -134,6 +146,7 @@ export function buildToolGroupMembership(
|
|
|
134
146
|
export function computeToolGroupMembership(
|
|
135
147
|
messages: readonly Message[],
|
|
136
148
|
indexOffset = 0,
|
|
149
|
+
width = 80,
|
|
137
150
|
): ReadonlyMap<number, ToolGroupMembership> {
|
|
138
|
-
return buildToolGroupMembership(detectToolGroups(messages, indexOffset), messages, indexOffset);
|
|
151
|
+
return buildToolGroupMembership(detectToolGroups(messages, indexOffset), messages, indexOffset, width);
|
|
139
152
|
}
|
|
@@ -39,4 +39,13 @@ export type BlockMeta = Omit<SdkBlockMeta, 'type'> & {
|
|
|
39
39
|
* individually while the group stays collapsed.
|
|
40
40
|
*/
|
|
41
41
|
groupMemberIndexes?: readonly number[];
|
|
42
|
+
/**
|
|
43
|
+
* The tool call's name, when this block renders a 'tool' result (or is the
|
|
44
|
+
* header of a 'tool_group' whose members share a run — the header carries
|
|
45
|
+
* the first member's name). Used to name the block honestly in action
|
|
46
|
+
* receipts (e.g. "tool result: exec") — see describeBlockForReceipt in
|
|
47
|
+
* handler-content-actions.ts. Undefined for non-tool block types and for
|
|
48
|
+
* standalone tool results with no recorded name.
|
|
49
|
+
*/
|
|
50
|
+
toolName?: string;
|
|
42
51
|
};
|
|
@@ -23,9 +23,8 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
25
25
|
import { join } from 'node:path';
|
|
26
|
-
import {
|
|
26
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
27
27
|
|
|
28
|
-
const { getUserSessionsDir } = operations;
|
|
29
28
|
|
|
30
29
|
/** One recorded turn boundary — the rewind coordinator's per-turn anchor. */
|
|
31
30
|
export interface TurnAnchor {
|
|
@@ -87,18 +86,18 @@ export function clearTurnAnchors(sessionId: string): void {
|
|
|
87
86
|
// --- Cross-resume persistence -------------------------------------------------
|
|
88
87
|
//
|
|
89
88
|
// The TUI mirrors the in-memory registry to a sidecar next to the session file.
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
89
|
+
// The directory comes off the caller's SessionSurface (`surface.sessionsDir`),
|
|
90
|
+
// the same handle the session JSONL itself is written through — so the sidecar
|
|
91
|
+
// and its session can never end up in different directories.
|
|
93
92
|
const ANCHOR_SIDECAR_VERSION = 1;
|
|
94
93
|
|
|
95
94
|
/** Absolute path to a session's anchor sidecar, or null when inputs are unusable. */
|
|
96
|
-
function anchorSidecarPath(sessionId: string,
|
|
97
|
-
if (!sessionId || !
|
|
95
|
+
function anchorSidecarPath(sessionId: string, surface: SessionSurface): string | null {
|
|
96
|
+
if (!sessionId || !surface.sessionsDir) return null;
|
|
98
97
|
// A sessionId is a filename component; refuse anything with path separators so
|
|
99
98
|
// a malformed id can never escape the sessions directory.
|
|
100
99
|
if (/[\\/]/.test(sessionId)) return null;
|
|
101
|
-
return join(
|
|
100
|
+
return join(surface.sessionsDir, `${sessionId}.anchors.json`);
|
|
102
101
|
}
|
|
103
102
|
|
|
104
103
|
function isTurnAnchor(value: unknown): value is TurnAnchor {
|
|
@@ -118,13 +117,13 @@ function isTurnAnchor(value: unknown): value is TurnAnchor {
|
|
|
118
117
|
* torn write must never break the turn that triggered it. Called after each
|
|
119
118
|
* `recordTurnAnchor` at TURN_COMPLETED.
|
|
120
119
|
*/
|
|
121
|
-
export function persistTurnAnchors(sessionId: string,
|
|
122
|
-
const path = anchorSidecarPath(sessionId,
|
|
120
|
+
export function persistTurnAnchors(sessionId: string, surface: SessionSurface): void {
|
|
121
|
+
const path = anchorSidecarPath(sessionId, surface);
|
|
123
122
|
if (!path) return;
|
|
124
123
|
const list = registry.get(sessionId) ?? [];
|
|
125
124
|
if (list.length === 0) return; // nothing to mirror yet
|
|
126
125
|
try {
|
|
127
|
-
mkdirSync(
|
|
126
|
+
mkdirSync(surface.sessionsDir, { recursive: true });
|
|
128
127
|
const payload = JSON.stringify({ version: ANCHOR_SIDECAR_VERSION, sessionId, anchors: list });
|
|
129
128
|
const tmp = `${path}.tmp-${process.pid}`;
|
|
130
129
|
writeFileSync(tmp, payload);
|
|
@@ -140,8 +139,8 @@ export function persistTurnAnchors(sessionId: string, workingDirectory: string):
|
|
|
140
139
|
* unreadable). Idempotent via `recordTurnAnchor`'s per-turnId dedup, so a resume
|
|
141
140
|
* that later re-records the same turn keeps a single entry.
|
|
142
141
|
*/
|
|
143
|
-
export function restoreTurnAnchors(sessionId: string,
|
|
144
|
-
const path = anchorSidecarPath(sessionId,
|
|
142
|
+
export function restoreTurnAnchors(sessionId: string, surface: SessionSurface): number {
|
|
143
|
+
const path = anchorSidecarPath(sessionId, surface);
|
|
145
144
|
if (!path || !existsSync(path)) return 0;
|
|
146
145
|
try {
|
|
147
146
|
const parsed = JSON.parse(readFileSync(path, 'utf8')) as { anchors?: unknown };
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import { journalPathFor, openTranscriptJournal, replayJournal } from './transcript-journal.ts';
|
|
36
36
|
import type { ConversationManager } from './conversation.ts';
|
|
37
37
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
38
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
38
39
|
|
|
39
40
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
40
41
|
|
|
@@ -145,9 +146,65 @@ export function replayJournalIntoConversation(
|
|
|
145
146
|
*/
|
|
146
147
|
export function replayJournalForSession(
|
|
147
148
|
options: Omit<ReplayIntoConversationOptions, 'journalPath'> & {
|
|
148
|
-
readonly
|
|
149
|
+
readonly surface: SessionSurface;
|
|
149
150
|
},
|
|
150
151
|
): ReplayIntoConversationResult {
|
|
151
|
-
const journalPath = journalPathFor(options.
|
|
152
|
+
const journalPath = journalPathFor(options.surface, options.sessionId);
|
|
152
153
|
return replayJournalIntoConversation({ ...options, journalPath });
|
|
153
154
|
}
|
|
155
|
+
|
|
156
|
+
// ─── Prompted crash-snapshot restore ────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** What a crash-recovery snapshot carries back from disk. */
|
|
159
|
+
export interface RecoverySnapshotPayload {
|
|
160
|
+
readonly messages: Array<Record<string, unknown>>;
|
|
161
|
+
readonly title?: string | undefined;
|
|
162
|
+
readonly titleSource?: string | undefined;
|
|
163
|
+
readonly timestamp?: number | undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface ApplyRecoverySnapshotResult {
|
|
167
|
+
/** Messages the conversation holds once the snapshot (plus any journal tail) is in place. */
|
|
168
|
+
readonly messageCount: number;
|
|
169
|
+
/** Journal records that post-dated the snapshot and were folded in on top of it. */
|
|
170
|
+
readonly journalReplay: ReplayIntoConversationResult;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Apply a crash-recovery snapshot the user explicitly asked to resume.
|
|
175
|
+
*
|
|
176
|
+
* This is deliberately the same sequence `resumeSessionCore` runs for a saved
|
|
177
|
+
* session — reset, fromJSON, rebuildHistory, then fold in any journal records
|
|
178
|
+
* newer than the snapshot — with one difference: the messages come from the
|
|
179
|
+
* recovery file the SDK just handed back (`consumeRecovery`) instead of the
|
|
180
|
+
* session store, because a session that crashed before its first clean save
|
|
181
|
+
* has no entry in the store to load. Nothing here decides WHETHER to restore;
|
|
182
|
+
* the caller has already asked the user (see runtime/recovery-prompt.ts).
|
|
183
|
+
*/
|
|
184
|
+
export function applyRecoverySnapshot(options: {
|
|
185
|
+
readonly snapshot: RecoverySnapshotPayload;
|
|
186
|
+
readonly sessionId: string;
|
|
187
|
+
readonly conversation: ConversationManager;
|
|
188
|
+
readonly surface: SessionSurface;
|
|
189
|
+
readonly persistSnapshot: (messages: ConversationMessageSnapshot[]) => void;
|
|
190
|
+
}): ApplyRecoverySnapshotResult {
|
|
191
|
+
const { snapshot, sessionId, conversation, surface, persistSnapshot } = options;
|
|
192
|
+
|
|
193
|
+
conversation.resetAll();
|
|
194
|
+
conversation.fromJSON({
|
|
195
|
+
messages: snapshot.messages as never[],
|
|
196
|
+
title: snapshot.title,
|
|
197
|
+
titleSource: snapshot.titleSource as never,
|
|
198
|
+
});
|
|
199
|
+
conversation.rebuildHistory();
|
|
200
|
+
|
|
201
|
+
const journalReplay = replayJournalForSession({
|
|
202
|
+
surface,
|
|
203
|
+
sessionId,
|
|
204
|
+
snapshotTimestamp: snapshot.timestamp ?? 0,
|
|
205
|
+
conversation,
|
|
206
|
+
persistSnapshot,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return { messageCount: conversation.getMessageCount(), journalReplay };
|
|
210
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-resume-core.ts — the ONE resume routine both resume seams call.
|
|
3
|
+
*
|
|
4
|
+
* Two independent call sites used to duplicate (and diverge from) this
|
|
5
|
+
* sequence: `/session resume` (session-workflow.ts) and the panel/session-
|
|
6
|
+
* browser resume (bootstrap-hook-bridge.ts's `createResumeSessionHandler`).
|
|
7
|
+
* Divergences found by audit:
|
|
8
|
+
* - the panel seam skipped `restoreTurnAnchors` (message-anchored /rewind
|
|
9
|
+
* silently had no anchors after a panel resume)
|
|
10
|
+
* - the panel seam skipped `conversation.resetAll()` before `fromJSON()`
|
|
11
|
+
* - the panel seam skipped the `selectModel` reselection fallback (it set
|
|
12
|
+
* `runtime.model` straight from the saved meta, never re-resolving
|
|
13
|
+
* through the live provider registry)
|
|
14
|
+
* - the panel seam duplicated the panel-reopen loop WITHOUT the
|
|
15
|
+
* modal-redirect skip, so a MIGRATE-TO-MODAL id could pop a modal
|
|
16
|
+
* mid-resume
|
|
17
|
+
*
|
|
18
|
+
* Both seams now call this module so those four behaviors (plus the
|
|
19
|
+
* panel-reopen-cap honesty note) are guaranteed identical by construction,
|
|
20
|
+
* not by copy-paste discipline between two files.
|
|
21
|
+
*
|
|
22
|
+
* Callers own everything ABOVE and AROUND this core sequence: printing or
|
|
23
|
+
* logging the outcome in their own idiom (`ctx.print` vs `conversation.log`),
|
|
24
|
+
* and any extra plumbing that only one seam performs (hookDispatcher.fire,
|
|
25
|
+
* sessionSpine.reopen, sharedSessionBroker.reopenSession,
|
|
26
|
+
* writeLastSessionPointer — all bootstrap-hook-bridge-only, pre-existing
|
|
27
|
+
* asymmetries out of this module's scope).
|
|
28
|
+
*/
|
|
29
|
+
import type { SessionManager, SessionMeta } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
30
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
31
|
+
import type { ConversationManager } from './conversation.ts';
|
|
32
|
+
import { restoreTurnAnchors } from './rewind-turn-anchors.ts';
|
|
33
|
+
import { replayJournalForSession, type ReplayIntoConversationResult } from './session-recovery.ts';
|
|
34
|
+
|
|
35
|
+
export interface SessionResumeRuntime {
|
|
36
|
+
sessionId: string;
|
|
37
|
+
model: string;
|
|
38
|
+
provider: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SessionResumePanelManager {
|
|
42
|
+
open(id: string): unknown;
|
|
43
|
+
show(): void;
|
|
44
|
+
getModalRedirect(id: string): string | undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SessionResumeDeps {
|
|
48
|
+
readonly sessionManager: Pick<SessionManager, 'load' | 'save'>;
|
|
49
|
+
readonly conversation: ConversationManager;
|
|
50
|
+
readonly runtime: SessionResumeRuntime;
|
|
51
|
+
/**
|
|
52
|
+
* The app's declare-once session-storage handle. Both resume seams pass the
|
|
53
|
+
* SAME one the runtime writes through, so the anchor sidecar and the
|
|
54
|
+
* transcript journal this routine reads are guaranteed to be the files the
|
|
55
|
+
* live session actually wrote — the scope can no longer be re-guessed per
|
|
56
|
+
* seam.
|
|
57
|
+
*/
|
|
58
|
+
readonly surface: SessionSurface;
|
|
59
|
+
readonly panelManager: SessionResumePanelManager;
|
|
60
|
+
/**
|
|
61
|
+
* Reselects the saved model through the live provider registry, falling
|
|
62
|
+
* back to the raw saved id on failure (a saved model may no longer exist
|
|
63
|
+
* locally). Omit to use the saved id directly without reselection.
|
|
64
|
+
*/
|
|
65
|
+
readonly selectModel?: (model: string) => Promise<{ readonly registryKey: string; readonly providerId: string }>;
|
|
66
|
+
readonly hydrateSessionUsage?: () => void;
|
|
67
|
+
/**
|
|
68
|
+
* Deliberate cap on how many saved panels are reopened at once — resuming
|
|
69
|
+
* into a workspace crowded with every panel that happened to be open is
|
|
70
|
+
* its own kind of surprise. Overflow beyond the cap is reported in the
|
|
71
|
+
* outcome (`panels.notReopened`), never silently dropped. Defaults to 4.
|
|
72
|
+
*/
|
|
73
|
+
readonly panelReopenLimit?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PanelReopenOutcome {
|
|
77
|
+
readonly reopened: readonly string[];
|
|
78
|
+
readonly movedToModal: readonly string[];
|
|
79
|
+
/** Saved panel ids beyond `panelReopenLimit` — not attempted, honestly reported (see /panels to open the rest). */
|
|
80
|
+
readonly notReopened: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface SessionResumeOutcome {
|
|
84
|
+
readonly meta: SessionMeta;
|
|
85
|
+
readonly resumedMessageCount: number;
|
|
86
|
+
readonly restoredAnchorCount: number;
|
|
87
|
+
readonly journalReplay: ReplayIntoConversationResult;
|
|
88
|
+
readonly panels: PanelReopenOutcome;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Exported so both session-workflow.ts's standalone `reopenPanelsFromReturnContext`
|
|
93
|
+
* (kept for its existing direct unit-test coverage — see
|
|
94
|
+
* session-workflow-panel-restore.test.ts) and `resumeSessionCore` below share
|
|
95
|
+
* the exact same default cap.
|
|
96
|
+
*/
|
|
97
|
+
export const DEFAULT_PANEL_REOPEN_LIMIT = 4;
|
|
98
|
+
|
|
99
|
+
export function reopenPanelsWithModalSkip(
|
|
100
|
+
panelManager: SessionResumePanelManager,
|
|
101
|
+
openPanelIds: readonly string[] | undefined,
|
|
102
|
+
limit: number,
|
|
103
|
+
): PanelReopenOutcome {
|
|
104
|
+
if (!openPanelIds || openPanelIds.length === 0) return { reopened: [], movedToModal: [], notReopened: [] };
|
|
105
|
+
const within = openPanelIds.slice(0, limit);
|
|
106
|
+
const overflow = openPanelIds.slice(limit);
|
|
107
|
+
const reopened: string[] = [];
|
|
108
|
+
const movedToModal: string[] = [];
|
|
109
|
+
for (const panelId of within) {
|
|
110
|
+
// A MIGRATE-TO-MODAL id has no panel to restore — a modal is not part of
|
|
111
|
+
// the saved panel layout. Skip it (don't pop a modal mid-resume) and note
|
|
112
|
+
// it once, rather than firing open() and revealing an empty workspace.
|
|
113
|
+
if (panelManager.getModalRedirect(panelId) !== undefined) {
|
|
114
|
+
movedToModal.push(panelId);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
panelManager.open(panelId);
|
|
119
|
+
reopened.push(panelId);
|
|
120
|
+
} catch {
|
|
121
|
+
// Ignore unknown or currently unavailable panel ids during resume.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (reopened.length > 0) panelManager.show();
|
|
125
|
+
return { reopened, movedToModal, notReopened: overflow };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The canonical resume sequence: load, reset + restore the conversation,
|
|
130
|
+
* restore rewind anchors, replay any post-snapshot journal records, hydrate
|
|
131
|
+
* footer usage, reselect the model, and reopen saved panels (modal-redirect
|
|
132
|
+
* aware, cap honestly reported).
|
|
133
|
+
*/
|
|
134
|
+
export async function resumeSessionCore(sessionId: string, deps: SessionResumeDeps): Promise<SessionResumeOutcome> {
|
|
135
|
+
const { meta, messages } = deps.sessionManager.load(sessionId);
|
|
136
|
+
|
|
137
|
+
deps.conversation.resetAll();
|
|
138
|
+
deps.conversation.fromJSON({
|
|
139
|
+
messages: messages as never[],
|
|
140
|
+
title: meta.title,
|
|
141
|
+
titleSource: meta.titleSource,
|
|
142
|
+
});
|
|
143
|
+
deps.conversation.rebuildHistory();
|
|
144
|
+
deps.runtime.sessionId = sessionId;
|
|
145
|
+
|
|
146
|
+
// Restore this session's message-anchored rewind anchors from its sidecar
|
|
147
|
+
// so /rewind works identically before and after the resume — the in-memory
|
|
148
|
+
// registry is process-local, so a fresh process starts with none for the
|
|
149
|
+
// loaded session.
|
|
150
|
+
const restoredAnchorCount = restoreTurnAnchors(sessionId, deps.surface);
|
|
151
|
+
|
|
152
|
+
// Journal replay: recover turns that post-date the loaded snapshot.
|
|
153
|
+
const journalReplay = replayJournalForSession({
|
|
154
|
+
surface: deps.surface,
|
|
155
|
+
sessionId,
|
|
156
|
+
snapshotTimestamp: meta.timestamp ?? 0,
|
|
157
|
+
conversation: deps.conversation,
|
|
158
|
+
persistSnapshot: (replayedMessages) => {
|
|
159
|
+
deps.sessionManager.save(sessionId, replayedMessages as never[], {
|
|
160
|
+
title: deps.conversation.title || meta.title,
|
|
161
|
+
model: meta.model,
|
|
162
|
+
provider: meta.provider,
|
|
163
|
+
timestamp: Date.now(),
|
|
164
|
+
titleSource: meta.titleSource,
|
|
165
|
+
returnContext: meta.returnContext,
|
|
166
|
+
// Machinery: this write exists only to durably close the journal gap
|
|
167
|
+
// the replay just filled. The user's own save/fork/rename acts stamp
|
|
168
|
+
// 'user' at their own call sites (session-workflow.ts).
|
|
169
|
+
saveSource: 'auto',
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Hydrate the footer's token counters from the resumed (+ journal-replayed)
|
|
175
|
+
// history now, before the caller renders.
|
|
176
|
+
deps.hydrateSessionUsage?.();
|
|
177
|
+
|
|
178
|
+
if (meta.model) {
|
|
179
|
+
if (deps.selectModel) {
|
|
180
|
+
try {
|
|
181
|
+
const selected = await deps.selectModel(meta.model);
|
|
182
|
+
deps.runtime.model = selected.registryKey;
|
|
183
|
+
deps.runtime.provider = selected.providerId;
|
|
184
|
+
} catch {
|
|
185
|
+
deps.runtime.model = meta.model; // model may not exist locally
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
deps.runtime.model = meta.model;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (meta.provider) deps.runtime.provider = meta.provider;
|
|
192
|
+
|
|
193
|
+
const panels = reopenPanelsWithModalSkip(
|
|
194
|
+
deps.panelManager,
|
|
195
|
+
meta.returnContext?.openPanels,
|
|
196
|
+
deps.panelReopenLimit ?? DEFAULT_PANEL_REOPEN_LIMIT,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
meta,
|
|
201
|
+
resumedMessageCount: deps.conversation.getMessageCount(),
|
|
202
|
+
restoredAnchorCount,
|
|
203
|
+
journalReplay,
|
|
204
|
+
panels,
|
|
205
|
+
};
|
|
206
|
+
}
|