@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -1613
  4. package/docs/README.md +4 -4
  5. package/docs/foundation-artifacts/operator-contract.json +1 -1
  6. package/package.json +7 -7
  7. package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
  8. package/src/cli/entrypoint.ts +5 -1
  9. package/src/cli/launch-auto-update.ts +63 -4
  10. package/src/cli/tui-startup.ts +131 -15
  11. package/src/config/surface.ts +21 -0
  12. package/src/config/tui-extension-settings.ts +9 -1
  13. package/src/core/bookmark-navigation.ts +44 -0
  14. package/src/core/conversation-line-cache.ts +87 -19
  15. package/src/core/conversation-rendering.ts +186 -28
  16. package/src/core/conversation-tool-groups.ts +152 -0
  17. package/src/core/conversation-types.ts +30 -3
  18. package/src/core/conversation.ts +17 -0
  19. package/src/core/rewind-turn-anchors.ts +12 -13
  20. package/src/core/session-recovery.ts +62 -7
  21. package/src/core/session-resume-core.ts +206 -0
  22. package/src/core/transcript-journal.ts +32 -6
  23. package/src/core/turn-event-wiring.ts +55 -12
  24. package/src/input/command-args-hint.ts +11 -2
  25. package/src/input/command-registry.ts +19 -0
  26. package/src/input/commands/checkpoint-runtime.ts +3 -3
  27. package/src/input/commands/health-runtime.ts +1 -3
  28. package/src/input/commands/local-runtime.ts +19 -2
  29. package/src/input/commands/rewind-runtime.ts +132 -1
  30. package/src/input/commands/runtime-services.ts +11 -1
  31. package/src/input/commands/secret-runtime.ts +1 -1
  32. package/src/input/commands/session-content.ts +9 -1
  33. package/src/input/commands/session-picker-filter.ts +28 -0
  34. package/src/input/commands/session-workflow.ts +62 -73
  35. package/src/input/commands/session.ts +36 -1
  36. package/src/input/commands/shell-core.ts +57 -5
  37. package/src/input/commands/update-runtime.ts +120 -4
  38. package/src/input/commands/websearch-runtime.ts +1 -1
  39. package/src/input/handler-content-actions.ts +63 -39
  40. package/src/input/handler-feed-routes.ts +17 -3
  41. package/src/input/handler-feed.ts +3 -0
  42. package/src/input/handler-interactions.ts +19 -23
  43. package/src/input/handler-modal-token-routes.ts +5 -0
  44. package/src/input/handler-types.ts +4 -1
  45. package/src/input/handler-ui-state.ts +6 -2
  46. package/src/input/handler.ts +19 -9
  47. package/src/input/search.ts +94 -2
  48. package/src/input/selection-modal-queue.ts +176 -0
  49. package/src/main.ts +55 -56
  50. package/src/panels/builtin/operations.ts +2 -1
  51. package/src/panels/provider-health-domains.ts +2 -2
  52. package/src/renderer/block-actions-overlay.ts +59 -0
  53. package/src/renderer/block-actions.ts +1 -3
  54. package/src/renderer/context-status-hint.ts +73 -5
  55. package/src/renderer/conversation-layout.ts +16 -0
  56. package/src/renderer/conversation-overlays.ts +6 -0
  57. package/src/renderer/help-overlay.ts +69 -53
  58. package/src/renderer/selection-modal-overlay.ts +66 -10
  59. package/src/renderer/shell-surface.ts +15 -0
  60. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  61. package/src/renderer/ui-factory.ts +1 -1
  62. package/src/runtime/bootstrap-command-context.ts +5 -1
  63. package/src/runtime/bootstrap-command-parts.ts +5 -2
  64. package/src/runtime/bootstrap-core.ts +3 -3
  65. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  66. package/src/runtime/bootstrap-shell.ts +31 -16
  67. package/src/runtime/bootstrap.ts +13 -18
  68. package/src/runtime/code-index-services.ts +19 -0
  69. package/src/runtime/durability-services.ts +11 -7
  70. package/src/runtime/index.ts +12 -1
  71. package/src/runtime/process-lifecycle.ts +63 -2
  72. package/src/runtime/recovery-autosave.ts +48 -0
  73. package/src/runtime/recovery-offer-wiring.ts +89 -0
  74. package/src/runtime/recovery-prompt.ts +312 -0
  75. package/src/runtime/resume-notice.ts +26 -21
  76. package/src/runtime/services.ts +14 -16
  77. package/src/runtime/session-liveness-marker.ts +120 -0
  78. package/src/runtime/session-pointer-surface.ts +22 -0
  79. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  80. package/src/runtime/session-storage-services.ts +41 -0
  81. package/src/runtime/workspace-checkpointing.ts +10 -2
  82. package/src/shell/recovery-input-helpers.ts +17 -98
  83. package/src/shell/retry-affordance.ts +123 -0
  84. package/src/shell/ui-openers.ts +9 -0
  85. package/src/utils/splash-lines.ts +1 -1
  86. 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
- /** TUI extends the SDK BlockMeta with rendering position fields. */
15
- export interface BlockMeta extends SdkBlockMeta {
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
+ };
@@ -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 { operations } from '@pellux/goodvibes-sdk/platform/runtime';
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
- // Surface is always 'tui' (the sessions the TUI reads/writes live under the same
91
- // scoped directory), matching how the session JSONL itself is persisted.
92
- const ANCHOR_SIDECAR_SURFACE = 'tui';
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, workingDirectory: string): string | null {
97
- if (!sessionId || !workingDirectory) return null;
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(getUserSessionsDir(workingDirectory, ANCHOR_SIDECAR_SURFACE), `${sessionId}.anchors.json`);
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, workingDirectory: string): void {
122
- const path = anchorSidecarPath(sessionId, workingDirectory);
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(getUserSessionsDir(workingDirectory, ANCHOR_SIDECAR_SURFACE), { recursive: true });
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, workingDirectory: string): number {
144
- const path = anchorSidecarPath(sessionId, workingDirectory);
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 (all three must call replayJournalForSession)
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. Ctrl+R crash recovery — blocking-input.ts, after `conversation.fromJSON`
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 homeDirectory: string;
149
+ readonly surface: SessionSurface;
151
150
  },
152
151
  ): ReplayIntoConversationResult {
153
- const journalPath = journalPathFor(options.homeDirectory, options.sessionId);
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
- * @param homeDirectory The goodvibes home directory (e.g. ~/.goodvibes).
152
- * @param sessionId The session identifier.
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(homeDirectory: string, sessionId: string): string {
155
- return join(homeDirectory, '.goodvibes', 'tui', `transcript-${sessionId}.journal`);
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 path: string;
252
- private readonly _sessionId: string;
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
- readonly workingDir: string;
68
- readonly homeDirectory: string;
69
- readonly sessionManager: PersistOptions['sessionManager'];
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
- workingDir, homeDirectory, sessionManager, gitStatusProvider,
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
- const transcriptJournal: TranscriptJournal = openTranscriptJournal(
149
- journalPathFor(homeDirectory, runtime.sessionId),
150
- runtime.sessionId,
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
- { workingDirectory: workingDir, homeDirectory, sessionManager },
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, workingDir);
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: { rename: '<name>', resume: '<id|name>', info: '<id>', export: '<id> [format]', search: '<query>', delete: '<id>' },
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