@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
|
@@ -11,8 +11,18 @@
|
|
|
11
11
|
|
|
12
12
|
import type { BlockMeta as SdkBlockMeta } from '@pellux/goodvibes-sdk/platform/core';
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* TUI extends the SDK BlockMeta with rendering position fields, plus a
|
|
16
|
+
* TUI-only block type: 'tool_group' is the synthetic header block that folds
|
|
17
|
+
* a run of >=2 consecutive tool-result messages under one collapsible header
|
|
18
|
+
* (see conversation-tool-groups.ts). Defined as an intersection rather than
|
|
19
|
+
* `interface X extends SdkBlockMeta` because TypeScript requires an extending
|
|
20
|
+
* interface's members to be subtypes of the base interface's — widening the
|
|
21
|
+
* `type` union that way is a compile error. Omit + intersection adds the new
|
|
22
|
+
* variant without touching the SDK's published type.
|
|
23
|
+
*/
|
|
24
|
+
export type BlockMeta = Omit<SdkBlockMeta, 'type'> & {
|
|
25
|
+
type: SdkBlockMeta['type'] | 'tool_group';
|
|
16
26
|
/** Index of this block (increments per renderable block). */
|
|
17
27
|
blockIndex: number;
|
|
18
28
|
/** First rendered line index in the history buffer. */
|
|
@@ -21,4 +31,21 @@ export interface BlockMeta extends SdkBlockMeta {
|
|
|
21
31
|
lineCount: number;
|
|
22
32
|
/** Stable key for collapse state persistence across rebuilds (e.g. msg_N). */
|
|
23
33
|
collapseKey: string;
|
|
24
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Absolute message indexes of every member of a folded tool-result group
|
|
36
|
+
* (see conversation-tool-groups.ts). Present only on 'tool_group' blocks —
|
|
37
|
+
* lets /expand also open each member's own collapse key in the same pass,
|
|
38
|
+
* since a folded member pushes no BlockMeta of its own to toggle
|
|
39
|
+
* individually while the group stays collapsed.
|
|
40
|
+
*/
|
|
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;
|
|
51
|
+
};
|
package/src/core/conversation.ts
CHANGED
|
@@ -644,6 +644,23 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
644
644
|
return nearest.blockIndex;
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
+
/** First rendered line for message `absoluteIdx` (undefined if never
|
|
648
|
+
* rendered). For a folded tool-group member this is the group's own
|
|
649
|
+
* header line, not the following message's position — see
|
|
650
|
+
* messageLineRegistry's doc. Flushes history if dirty. */
|
|
651
|
+
public getMessageLine(absoluteIdx: number): number | undefined {
|
|
652
|
+
this.flushHistory();
|
|
653
|
+
return this.messageLineRegistry[absoluteIdx];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Set a collapseKey's state directly, bypassing block lookup — needed for
|
|
657
|
+
* a key with no BlockMeta yet (a folded tool-group member's own
|
|
658
|
+
* `msg_<idx>` key only becomes a real block once its group expands). */
|
|
659
|
+
public setCollapsed(collapseKey: string, collapsed: boolean): void {
|
|
660
|
+
this.collapseState.set(collapseKey, collapsed);
|
|
661
|
+
this.markDirty();
|
|
662
|
+
}
|
|
663
|
+
|
|
647
664
|
/** Returns a read-only view of the block registry for external consumers. */
|
|
648
665
|
public getBlockRegistry(): readonly BlockMeta[] {
|
|
649
666
|
return this.blockRegistry;
|
|
@@ -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 };
|
|
@@ -8,14 +8,12 @@
|
|
|
8
8
|
* it replays those records onto the live conversation and writes a fresh
|
|
9
9
|
* snapshot so the gap is permanently closed.
|
|
10
10
|
*
|
|
11
|
-
* Seams (
|
|
12
|
-
*
|
|
11
|
+
* Seams (both must call replayJournalForSession)
|
|
12
|
+
* ───────────────────────────────────────────────
|
|
13
13
|
* 1. CLI / command resume — session-workflow.ts, after `fromJSON` +
|
|
14
14
|
* `rebuildHistory` complete. Handles --continue, --resume, /session resume,
|
|
15
15
|
* and --fork.
|
|
16
|
-
* 2.
|
|
17
|
-
* in the Ctrl+R branch. Handles SIGKILL-era recovery files.
|
|
18
|
-
* 3. In-TUI panel resume — bootstrap-hook-bridge.ts
|
|
16
|
+
* 2. In-TUI panel resume — bootstrap-hook-bridge.ts
|
|
19
17
|
* `createResumeSessionHandler`, after `options.runtime.sessionId` is
|
|
20
18
|
* assigned. Handles the session browser / panel-driven resume.
|
|
21
19
|
*
|
|
@@ -37,6 +35,7 @@
|
|
|
37
35
|
import { journalPathFor, openTranscriptJournal, replayJournal } from './transcript-journal.ts';
|
|
38
36
|
import type { ConversationManager } from './conversation.ts';
|
|
39
37
|
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
38
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
40
39
|
|
|
41
40
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
|
42
41
|
|
|
@@ -147,9 +146,65 @@ export function replayJournalIntoConversation(
|
|
|
147
146
|
*/
|
|
148
147
|
export function replayJournalForSession(
|
|
149
148
|
options: Omit<ReplayIntoConversationOptions, 'journalPath'> & {
|
|
150
|
-
readonly
|
|
149
|
+
readonly surface: SessionSurface;
|
|
151
150
|
},
|
|
152
151
|
): ReplayIntoConversationResult {
|
|
153
|
-
const journalPath = journalPathFor(options.
|
|
152
|
+
const journalPath = journalPathFor(options.surface, options.sessionId);
|
|
154
153
|
return replayJournalIntoConversation({ ...options, journalPath });
|
|
155
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
|
+
}
|
|
@@ -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
|
|