@pellux/goodvibes-tui 1.19.5 → 1.19.6

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 CHANGED
@@ -4,6 +4,12 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.19.6] - 2026-07-24
8
+
9
+ ### Changes
10
+
11
+ - 4e5bbca7 fix: explicit-only session restore at launch; fold tool results into collapsible groups
12
+
7
13
  ## [1.19.5] - 2026-07-18
8
14
 
9
15
  ### Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-1.19.5-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-1.19.6-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "1.19.5",
3
+ "version": "1.19.6",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -131,10 +131,10 @@
131
131
  "typescript": "^5.9.3"
132
132
  },
133
133
  "optionalDependencies": {
134
- "@pellux/goodvibes-tui-linux-x64": "1.19.5",
135
- "@pellux/goodvibes-tui-linux-arm64": "1.19.5",
136
- "@pellux/goodvibes-tui-darwin-x64": "1.19.5",
137
- "@pellux/goodvibes-tui-darwin-arm64": "1.19.5"
134
+ "@pellux/goodvibes-tui-linux-x64": "1.19.6",
135
+ "@pellux/goodvibes-tui-linux-arm64": "1.19.6",
136
+ "@pellux/goodvibes-tui-darwin-x64": "1.19.6",
137
+ "@pellux/goodvibes-tui-darwin-arm64": "1.19.6"
138
138
  },
139
139
  "trustedDependencies": [],
140
140
  "overrides": {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * bookmark-navigation.ts — fallback resolution for a bookmark whose direct
3
+ * BlockMeta lookup misses.
4
+ *
5
+ * A bookmark stores a block's collapseKey at the time it was set (see
6
+ * handler-content-actions.ts's handleBookmark). A tool-result message that is
7
+ * a non-owning member of a folded tool-result group (see
8
+ * conversation-tool-groups.ts) pushes no BlockMeta of its own while the group
9
+ * stays collapsed — its own collapseKey (`msg_<absoluteIdx>`) never appears
10
+ * in getBlockRegistry() until the group is expanded. A direct
11
+ * find(entry => entry.collapseKey === key) then reports "not found", even
12
+ * though the message is still present in the transcript, just folded under
13
+ * its group's header.
14
+ *
15
+ * Resolution: parse the message's absolute index out of the `msg_<idx>` key
16
+ * and resolve it through ConversationManager.getMessageLine(idx) instead of
17
+ * the block registry. That registry is keyed by absolute message index and,
18
+ * for a folded group member, already resolves to the group's own header line
19
+ * rather than the position of whatever renders next (see getMessageLine's
20
+ * doc and conversation-rendering.ts / conversation-line-cache.ts, which
21
+ * anchor a folded member's registered line at its group's header) — landing
22
+ * on the header is an honest, reachable result rather than a false
23
+ * "not found".
24
+ */
25
+
26
+ import type { ConversationManager } from './conversation.ts';
27
+
28
+ const MESSAGE_BOOKMARK_KEY = /^msg_(\d+)$/;
29
+
30
+ /**
31
+ * Resolve a bookmark key that missed a direct BlockMeta lookup. Returns the
32
+ * line to scroll to, or null when the key isn't a message-scoped bookmark
33
+ * (`msg_<idx>`) or that message index has never been rendered.
34
+ */
35
+ export function resolveFoldedBookmarkLine(
36
+ conversation: Pick<ConversationManager, 'getMessageLine'>,
37
+ key: string,
38
+ ): number | null {
39
+ const match = MESSAGE_BOOKMARK_KEY.exec(key);
40
+ if (!match) return null;
41
+ const absoluteIdx = Number(match[1]);
42
+ const line = conversation.getMessageLine(absoluteIdx);
43
+ return line === undefined ? null : line;
44
+ }
@@ -25,6 +25,10 @@
25
25
  * - the absolute message index (embedded in every collapseKey and used for the
26
26
  * system-message kind lookup)
27
27
  * - the system-message kind (drives the error-navigation registry side effect)
28
+ * - this message's tool-group membership (see conversation-tool-groups.ts):
29
+ * whether it's folded under a group header, which member owns that header,
30
+ * and the group's honest tool/line counts — a group growing as new tool
31
+ * results stream in changes these for every existing member
28
32
  * - the live values of every collapseState key the render READS (recorded via a
29
33
  * proxy during a miss; a collapse toggle flips a recorded value and
30
34
  * invalidates exactly the owning message)
@@ -50,7 +54,9 @@ import {
50
54
  renderConversationToolMessage,
51
55
  renderConversationUserMessage,
52
56
  collectCompletedToolCallIds,
57
+ isFoldedGroupMember,
53
58
  } from './conversation-rendering.ts';
59
+ import { computeToolGroupMembership, type ToolGroupMembership } from './conversation-tool-groups.ts';
54
60
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
55
61
  // SystemMessageKind imported from runtime directly to avoid a cycle, mirroring
56
62
  // conversation-rendering.ts's own import.
@@ -96,17 +102,42 @@ interface KeyMeta {
96
102
  readonly blockBase: number;
97
103
  readonly kind: SystemMessageKind | undefined;
98
104
  /**
99
- * True when this (assistant) message has a tool call with no result yet — it
100
- * renders as pending rather than done. Keyed so the entry invalidates and
101
- * re-renders ✓ once the tool result arrives. (item 2c.)
105
+ * Per-call completion signature for an assistant message's tool calls (see
106
+ * pendingToolKeyOf) — undefined when the message has none. Each call's id
107
+ * is paired with whether it has a matching result yet, in call order, so a
108
+ * SINGLE call finishing invalidates and re-renders that call's glyph from
109
+ * ◌ to ✓ immediately. A prior aggregate boolean only flipped once EVERY
110
+ * call in the message had completed, so in a multi-call turn the earlier
111
+ * calls kept showing ◌ until the last result arrived.
102
112
  */
103
- readonly hasPendingTool: boolean;
113
+ readonly pendingToolKey: string | undefined;
114
+ /**
115
+ * Tool-group membership fields (see conversation-tool-groups.ts), flattened
116
+ * so a change invalidates the cache like any other structural input — e.g.
117
+ * a group growing from 2 to 3 members as tool results stream in changes
118
+ * groupToolCount/groupTotalLines for every existing member, and must
119
+ * re-render all of them with the updated header counts. `groupKey` is
120
+ * undefined when the message isn't part of a folded group.
121
+ */
122
+ readonly groupKey: string | undefined;
123
+ readonly groupIsFirst: boolean;
124
+ readonly groupToolCount: number;
125
+ readonly groupTotalLines: number;
104
126
  }
105
127
 
106
- /** Whether an assistant message has any tool call still awaiting a result. */
107
- function hasPendingToolCall(m: Message, completed: ReadonlySet<string>): boolean {
108
- if (m.role !== 'assistant' || !m.toolCalls) return false;
109
- return m.toolCalls.some((tc) => tc.id === undefined || !completed.has(tc.id));
128
+ /**
129
+ * Per-call completion signature for an assistant message's tool calls, in
130
+ * call order — `id:0` or `id:1` per call, joined. Undefined for a message
131
+ * with no tool calls at all (nothing to key). Comparing this instead of an
132
+ * aggregate boolean lets exactly the calls whose completion status changed
133
+ * invalidate the entry, rather than waiting for every call in the message to
134
+ * complete before any of them re-renders as done.
135
+ */
136
+ function pendingToolKeyOf(m: Message, completed: ReadonlySet<string>): string | undefined {
137
+ if (m.role !== 'assistant' || !m.toolCalls || m.toolCalls.length === 0) return undefined;
138
+ return m.toolCalls
139
+ .map((tc) => `${tc.id ?? ''}:${tc.id !== undefined && completed.has(tc.id) ? 1 : 0}`)
140
+ .join('|');
110
141
  }
111
142
 
112
143
  interface CacheEntry {
@@ -288,31 +319,51 @@ export class MessageLineCache {
288
319
  // Tool calls with no matching tool-result message are still pending; the
289
320
  // render context and the cache key both depend on this. (item 2c.)
290
321
  const completedToolCallIds = collectCompletedToolCallIds(messages);
291
- const renderContext: ConversationRenderContext = { ...context, completedToolCallIds };
322
+ // Fold runs of >=2 consecutive tool-result messages sharing one assistant
323
+ // turn under a single collapsible header (see conversation-tool-groups.ts).
324
+ // Computed once per pass, over the same slice+offset appendConversationMessages
325
+ // would see, so both entry points fold identically.
326
+ const toolGroupMembership = computeToolGroupMembership(messages, msgIndexOffset);
327
+ const renderContext: ConversationRenderContext = { ...context, completedToolCallIds, toolGroupMembership };
292
328
 
293
329
  const touched = new Set<number>();
330
+ // Header line of each currently-open tool-group, keyed by groupKey — the
331
+ // first member's own registered line, recorded as that member is reached
332
+ // below so later (non-first) members of the SAME group can resolve to it.
333
+ const groupHeaderLines = new Map<string, number>();
294
334
 
295
335
  for (let i = 0; i < messages.length; i++) {
296
336
  const message = messages[i]!;
297
337
  const absoluteIdx = msgIndexOffset + i;
298
338
  const base = context.history.getLineCount();
299
339
  const blockBase = context.blockRegistry.length;
300
- messageLineRegistry[absoluteIdx] = base;
301
340
 
302
341
  const uncacheable = absoluteIdx === streamingPlaceholderAbsIdx;
303
342
  const kind = context.messageKindRegistry.get(absoluteIdx);
304
- const hasPendingTool = hasPendingToolCall(message, completedToolCallIds);
343
+ const pendingToolKey = pendingToolKeyOf(message, completedToolCallIds);
344
+ const groupMembership = toolGroupMembership.get(absoluteIdx);
345
+
346
+ if (groupMembership?.isFirst) groupHeaderLines.set(groupMembership.groupKey, base);
347
+ // A folded (non-first, currently collapsed) group member renders zero
348
+ // lines of its own — `base` here is just wherever the buffer happens to
349
+ // sit after the last member that DID render, which is the position the
350
+ // NEXT real content starts at, not this message's own position. Anchor
351
+ // it at the group's header line instead, so transcript-event
352
+ // navigation lands on the group rather than skipping past it.
353
+ messageLineRegistry[absoluteIdx] = isFoldedGroupMember(groupMembership, context.collapseState)
354
+ ? (groupHeaderLines.get(groupMembership!.groupKey) ?? base)
355
+ : base;
305
356
 
306
357
  if (!uncacheable) {
307
358
  const existing = this.entries.get(absoluteIdx);
308
- if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, hasPendingTool, context.collapseState)) {
359
+ if (existing && this.isValid(existing, message, width, cfg, blockBase, kind, pendingToolKey, groupMembership, context.collapseState)) {
309
360
  this.applyEntry(context, existing, base);
310
361
  touched.add(absoluteIdx);
311
362
  continue;
312
363
  }
313
364
  }
314
365
 
315
- const entry = this.renderScratch(renderContext, message, width, absoluteIdx, cfg, blockBase, kind, hasPendingTool);
366
+ const entry = this.renderScratch(renderContext, message, width, absoluteIdx, cfg, blockBase, kind, pendingToolKey, groupMembership);
316
367
  this.applyEntry(context, entry, base);
317
368
  if (!uncacheable) {
318
369
  this.entries.set(absoluteIdx, entry);
@@ -338,7 +389,8 @@ export class MessageLineCache {
338
389
  cfg: RenderConfig,
339
390
  blockBase: number,
340
391
  kind: SystemMessageKind | undefined,
341
- hasPendingTool: boolean,
392
+ pendingToolKey: string | undefined,
393
+ groupMembership: ToolGroupMembership | undefined,
342
394
  collapseState: Map<string, boolean>,
343
395
  ): boolean {
344
396
  const k = entry.keyMeta;
@@ -351,7 +403,11 @@ export class MessageLineCache {
351
403
  k.showReasoningSummary !== cfg.showReasoningSummary ||
352
404
  k.blockBase !== blockBase ||
353
405
  k.kind !== kind ||
354
- k.hasPendingTool !== hasPendingTool
406
+ k.pendingToolKey !== pendingToolKey ||
407
+ k.groupKey !== groupMembership?.groupKey ||
408
+ k.groupIsFirst !== (groupMembership?.isFirst ?? false) ||
409
+ k.groupToolCount !== (groupMembership?.toolCount ?? 0) ||
410
+ k.groupTotalLines !== (groupMembership?.totalLines ?? 0)
355
411
  ) {
356
412
  return false;
357
413
  }
@@ -376,7 +432,8 @@ export class MessageLineCache {
376
432
  cfg: RenderConfig,
377
433
  blockBase: number,
378
434
  kind: SystemMessageKind | undefined,
379
- hasPendingTool: boolean,
435
+ pendingToolKey: string | undefined,
436
+ groupMembership: ToolGroupMembership | undefined,
380
437
  ): CacheEntry {
381
438
  const scratchLines: Line[] = [];
382
439
  const scratchHistory = {
@@ -401,11 +458,18 @@ export class MessageLineCache {
401
458
  configManager: context.configManager,
402
459
  splashOptions: context.splashOptions,
403
460
  completedToolCallIds: context.completedToolCallIds,
461
+ toolGroupMembership: context.toolGroupMembership,
404
462
  };
405
463
 
406
464
  renderOne(scratchCtx, message, width, absoluteIdx, cfg);
407
- // Trailing blank line, exactly as appendConversationMessages appends per message.
408
- scratchLines.push(createEmptyLine(width));
465
+ // Trailing blank line, exactly as appendConversationMessages appends per
466
+ // message — except a non-owning member of a currently-collapsed
467
+ // tool-result group (see conversation-tool-groups.ts) rendered nothing
468
+ // above and gets no filler line either; every other message keeps the
469
+ // unconditional separator.
470
+ if (!isFoldedGroupMember(groupMembership, context.collapseState)) {
471
+ scratchLines.push(createEmptyLine(width));
472
+ }
409
473
 
410
474
  const collapseDeps: Array<[string, boolean | undefined]> = [];
411
475
  for (const key of readKeys) collapseDeps.push([key, context.collapseState.get(key)]);
@@ -420,7 +484,11 @@ export class MessageLineCache {
420
484
  showReasoningSummary: cfg.showReasoningSummary,
421
485
  blockBase,
422
486
  kind,
423
- hasPendingTool,
487
+ pendingToolKey,
488
+ groupKey: groupMembership?.groupKey,
489
+ groupIsFirst: groupMembership?.isFirst ?? false,
490
+ groupToolCount: groupMembership?.toolCount ?? 0,
491
+ groupTotalLines: groupMembership?.totalLines ?? 0,
424
492
  },
425
493
  contentSig: contentSigOf(message),
426
494
  lines: scratchLines,
@@ -15,6 +15,7 @@ import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
15
15
  import { renderConversationCollapsedFragment, renderConversationEventLine } from '../renderer/conversation-surface.ts';
16
16
  import { GLYPHS } from '../renderer/ui-primitives.ts';
17
17
  import type { BlockMeta } from './conversation-types.ts';
18
+ import { computeToolGroupMembership, type ToolGroupMembership } from './conversation-tool-groups.ts';
18
19
  import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
19
20
  import { parseDiffForApply } from '@pellux/goodvibes-sdk/platform/core';
20
21
  import { extractUserDisplayText, COMPACTION_HANDOFF_HEADER } from '@pellux/goodvibes-sdk/platform/core';
@@ -82,6 +83,13 @@ export interface ConversationRenderContext {
82
83
  * behaviour. (item 2c.)
83
84
  */
84
85
  readonly completedToolCallIds?: ReadonlySet<string>;
86
+ /**
87
+ * Per-tool-message group membership for the currently rendered slice (see
88
+ * conversation-tool-groups.ts). A message index absent from this map (or an
89
+ * undefined map itself) isn't part of a folded run of tool results and
90
+ * renders exactly as it always has.
91
+ */
92
+ readonly toolGroupMembership?: ReadonlyMap<number, ToolGroupMembership>;
85
93
  }
86
94
 
87
95
  export function renderConversationUserMessage(
@@ -316,6 +324,69 @@ export function renderConversationSystemMessage(
316
324
  }
317
325
  }
318
326
 
327
+ /**
328
+ * True when `message` is a non-owning member of a folded tool-result group
329
+ * (see conversation-tool-groups.ts) that is currently collapsed — it rendered
330
+ * nothing (renderConversationToolMessage returns right after the header-owning
331
+ * member's header line), so the per-message trailing blank-line separator is
332
+ * skipped for it too. The header-owning ("first") member always gets its
333
+ * separator since it always renders at least the header line; any member of
334
+ * an EXPANDED group renders in full and keeps its separator as well.
335
+ */
336
+ export function isFoldedGroupMember(
337
+ membership: ToolGroupMembership | undefined,
338
+ collapseState: ReadonlyMap<string, boolean>,
339
+ ): boolean {
340
+ return membership !== undefined && !membership.isFirst && (collapseState.get(membership.groupKey) ?? true);
341
+ }
342
+
343
+ /**
344
+ * Render the synthetic header line for a folded run of >=2 tool-result
345
+ * messages: one line + one BlockMeta (type 'tool_group'), shared by every
346
+ * member under `membership.groupKey`. Returns whether the group is currently
347
+ * collapsed, establishing the collapsed-by-default state on first render —
348
+ * mirrors the has/set default-establishment idiom used by every other
349
+ * collapsible block in this file.
350
+ */
351
+ function renderToolGroupHeader(
352
+ context: ConversationRenderContext,
353
+ width: number,
354
+ membership: ToolGroupMembership,
355
+ ): boolean {
356
+ const T = activeTheme();
357
+ const isCollapsed = context.collapseState.has(membership.groupKey)
358
+ ? context.collapseState.get(membership.groupKey)!
359
+ : true;
360
+ if (!context.collapseState.has(membership.groupKey)) {
361
+ context.collapseState.set(membership.groupKey, true);
362
+ }
363
+
364
+ const blockIdx = context.blockRegistry.length;
365
+ const startLine = context.history.getLineCount();
366
+ context.history.addLine(renderConversationEventLine(width, {
367
+ marker: GLYPHS.surface.altCursor,
368
+ markerFg: T.toolAccent,
369
+ label: 'tool results',
370
+ labelFg: T.toolAccent,
371
+ detailFg: '244',
372
+ }, [
373
+ { text: ` ${membership.toolCount} tool${membership.toolCount === 1 ? '' : 's'} `, fg: T.toolAccent },
374
+ { text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${membership.totalLines} line${membership.totalLines === 1 ? '' : 's'} `, fg: '244', dim: true },
375
+ ]));
376
+
377
+ context.blockRegistry.push({
378
+ blockIndex: blockIdx,
379
+ collapseKey: membership.groupKey,
380
+ type: 'tool_group',
381
+ startLine,
382
+ lineCount: 1,
383
+ rawContent: `${membership.toolCount} tool result${membership.toolCount === 1 ? '' : 's'} folded (${membership.totalLines} line${membership.totalLines === 1 ? '' : 's'} total)`,
384
+ groupMemberIndexes: membership.memberIndexes,
385
+ });
386
+
387
+ return isCollapsed;
388
+ }
389
+
319
390
  export function renderConversationToolMessage(
320
391
  context: ConversationRenderContext,
321
392
  message: Extract<Message, { role: 'tool' }>,
@@ -323,6 +394,17 @@ export function renderConversationToolMessage(
323
394
  msgIdx: number,
324
395
  ): void {
325
396
  const T = activeTheme();
397
+ const groupMembership = context.toolGroupMembership?.get(msgIdx);
398
+ if (groupMembership) {
399
+ const collapsed = groupMembership.isFirst
400
+ ? renderToolGroupHeader(context, width, groupMembership)
401
+ : (context.collapseState.get(groupMembership.groupKey) ?? true);
402
+ // Folded: the header (rendered above, once, by the first member) is the
403
+ // group's entire visible representation while collapsed — no member,
404
+ // first or not, renders its own header/body/BlockMeta.
405
+ if (collapsed) return;
406
+ }
407
+
326
408
  const collapseKey = `msg_${msgIdx}`;
327
409
  const blockIdx = context.blockRegistry.length;
328
410
  const startLine = context.history.getLineCount();
@@ -458,23 +540,53 @@ export function appendConversationMessages(
458
540
  const renderContext: ConversationRenderContext = context.completedToolCallIds !== undefined
459
541
  ? context
460
542
  : { ...context, completedToolCallIds: collectCompletedToolCallIds(messages) };
543
+ // Fold runs of >=2 consecutive tool-result messages sharing one assistant
544
+ // turn under a single collapsible header (see conversation-tool-groups.ts),
545
+ // unless the caller already supplied membership (the line-cache path
546
+ // computes it once per renderInto call and threads it through).
547
+ const toolGroupMembership = renderContext.toolGroupMembership
548
+ ?? computeToolGroupMembership(messages, msgIndexOffset);
549
+ const groupedContext: ConversationRenderContext = renderContext.toolGroupMembership !== undefined
550
+ ? renderContext
551
+ : { ...renderContext, toolGroupMembership };
552
+
553
+ // Header line of each currently-open tool-group, keyed by groupKey — the
554
+ // first member's own registered line, recorded as that member is reached
555
+ // below so later (non-first) members of the SAME group can resolve to it
556
+ // instead of whatever position the buffer happens to be at when a folded
557
+ // (zero-line) member is processed. See isFoldedGroupMember's doc.
558
+ const groupHeaderLines = new Map<string, number>();
461
559
 
462
560
  for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
463
561
  const message = messages[msgIdx];
464
562
  // absoluteIdx aligns the slice-relative loop counter with the absolute
465
563
  // message index used as the key in messageKindRegistry and messageLineRegistry.
466
564
  const absoluteIdx = msgIndexOffset + msgIdx;
467
- messageLineRegistry[absoluteIdx] = renderContext.history.getLineCount();
565
+ const membership = toolGroupMembership.get(absoluteIdx);
566
+ const currentLine = groupedContext.history.getLineCount();
567
+ if (membership?.isFirst) groupHeaderLines.set(membership.groupKey, currentLine);
568
+ // A folded (non-first, currently collapsed) group member renders zero
569
+ // lines of its own — `currentLine` here is just wherever the buffer
570
+ // happens to sit after the last member that DID render, which is the
571
+ // position the NEXT real content starts at, not this message's own
572
+ // position. Anchor it at the group's header line instead, so
573
+ // transcript-event navigation lands on the group rather than skipping
574
+ // past it to the following message.
575
+ messageLineRegistry[absoluteIdx] = isFoldedGroupMember(membership, groupedContext.collapseState)
576
+ ? (groupHeaderLines.get(membership!.groupKey) ?? currentLine)
577
+ : currentLine;
468
578
  if (message.role === 'user') {
469
- renderConversationUserMessage(renderContext, message, width, absoluteIdx);
579
+ renderConversationUserMessage(groupedContext, message, width, absoluteIdx);
470
580
  } else if (message.role === 'assistant') {
471
- renderConversationAssistantMessage(renderContext, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
581
+ renderConversationAssistantMessage(groupedContext, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
472
582
  } else if (message.role === 'system') {
473
- renderConversationSystemMessage(renderContext, message, width, absoluteIdx);
583
+ renderConversationSystemMessage(groupedContext, message, width, absoluteIdx);
474
584
  } else if (message.role === 'tool') {
475
- renderConversationToolMessage(renderContext, message, width, absoluteIdx);
585
+ renderConversationToolMessage(groupedContext, message, width, absoluteIdx);
586
+ }
587
+ if (!isFoldedGroupMember(membership, groupedContext.collapseState)) {
588
+ groupedContext.history.addLine(createEmptyLine(width));
476
589
  }
477
- renderContext.history.addLine(createEmptyLine(width));
478
590
  }
479
591
  }
480
592
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * conversation-tool-groups.ts — detects runs of consecutive tool-result
3
+ * messages that belong to one assistant turn, so the transcript can fold them
4
+ * under a single collapsible group header instead of rendering one
5
+ * "tool result" header per call. Before this module, an assistant turn with
6
+ * N tool calls produced N independent header+body blocks in a row — visual
7
+ * spam once N is more than one or two.
8
+ *
9
+ * A run only counts as a group when it has >= 2 matched tool-result messages:
10
+ * a single result renders exactly as it always has (see
11
+ * renderConversationToolMessage in conversation-rendering.ts, which only
12
+ * consults this module's output when a membership entry exists).
13
+ *
14
+ * Pure function of the message slice — no rendering, no width, no side
15
+ * effects. Called once per render pass (appendConversationMessages /
16
+ * MessageLineCache.renderInto) over the currently-visible message slice, with
17
+ * the absolute index of the slice's first message so group keys and member
18
+ * indexes line up with the same collapseKey / blockRegistry indices a cold
19
+ * render would produce (messages may be a post-clearDisplay slice, not the
20
+ * full snapshot — see appendConversationMessages's msgIndexOffset doc).
21
+ */
22
+
23
+ import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
24
+
25
+ type Message = ConversationMessageSnapshot;
26
+
27
+ /** One consolidated run of tool-result messages sharing one assistant turn. */
28
+ export interface ToolGroup {
29
+ /** Absolute index of the assistant message that owns this group. */
30
+ readonly assistantIdx: number;
31
+ /** Absolute indexes of the matched tool-result messages, in order. */
32
+ readonly toolMessageIndexes: readonly number[];
33
+ }
34
+
35
+ /**
36
+ * Per-tool-message membership, keyed by the ABSOLUTE index of the tool-result
37
+ * message (the owning assistant message is never a key — only its tool-result
38
+ * children are). `isFirst` marks the member whose render site owns the
39
+ * group's header line and BlockMeta; every other member renders nothing while
40
+ * the group is collapsed (see renderConversationToolMessage).
41
+ */
42
+ export interface ToolGroupMembership {
43
+ readonly groupKey: string;
44
+ readonly isFirst: boolean;
45
+ /** Matched tool-result messages in the group (the header's "N tools"). */
46
+ readonly toolCount: number;
47
+ /** Sum of each member's raw content line count (the header's "N lines") —
48
+ * the same count each member already shows on its own "N lines" badge, so
49
+ * the group total is honest by construction, not an estimate. */
50
+ readonly totalLines: number;
51
+ /**
52
+ * Absolute indexes of every tool-result message in the group, in order —
53
+ * the same list for every member. Carried onto the group's BlockMeta so a
54
+ * single expand pass can also open each member's own collapse key
55
+ * (`msg_<idx>`), since a folded member pushes no BlockMeta of its own to
56
+ * toggle individually (see local-runtime.ts's toggleBlocks).
57
+ */
58
+ readonly memberIndexes: readonly number[];
59
+ }
60
+
61
+ function rawLineCount(content: string): number {
62
+ return content.split('\n').length;
63
+ }
64
+
65
+ /**
66
+ * Scan `messages` for assistant-message runs of >=2 consecutive matching
67
+ * tool-result messages. A run breaks on the first message that isn't a
68
+ * matching tool result: any non-tool message (interleaved user/system/
69
+ * assistant content), or a tool message whose callId isn't one of the
70
+ * assistant's own toolCalls ids (a result belonging to a different turn never
71
+ * gets folded into a group it doesn't belong to).
72
+ */
73
+ export function detectToolGroups(
74
+ messages: readonly Message[],
75
+ indexOffset = 0,
76
+ ): ToolGroup[] {
77
+ const groups: ToolGroup[] = [];
78
+
79
+ for (let i = 0; i < messages.length; i++) {
80
+ const message = messages[i];
81
+ if (message.role !== 'assistant' || !message.toolCalls || message.toolCalls.length === 0) continue;
82
+ const callIds = new Set(
83
+ message.toolCalls
84
+ .map((tc) => tc.id)
85
+ .filter((id): id is string => id !== undefined),
86
+ );
87
+ if (callIds.size === 0) continue;
88
+
89
+ const toolMessageIndexes: number[] = [];
90
+ let j = i + 1;
91
+ while (j < messages.length) {
92
+ const next = messages[j];
93
+ if (next.role !== 'tool' || !next.callId || !callIds.has(next.callId)) break;
94
+ toolMessageIndexes.push(indexOffset + j);
95
+ j++;
96
+ }
97
+
98
+ if (toolMessageIndexes.length >= 2) {
99
+ groups.push({ assistantIdx: indexOffset + i, toolMessageIndexes });
100
+ }
101
+ }
102
+
103
+ return groups;
104
+ }
105
+
106
+ /** Build the per-tool-message membership lookup used by the renderer/cache. */
107
+ export function buildToolGroupMembership(
108
+ groups: readonly ToolGroup[],
109
+ messages: readonly Message[],
110
+ indexOffset: number,
111
+ ): ReadonlyMap<number, ToolGroupMembership> {
112
+ const membership = new Map<number, ToolGroupMembership>();
113
+ for (const group of groups) {
114
+ const groupKey = `group_${group.assistantIdx}`;
115
+ let totalLines = 0;
116
+ for (const absIdx of group.toolMessageIndexes) {
117
+ const message = messages[absIdx - indexOffset];
118
+ if (message && message.role === 'tool') totalLines += rawLineCount(message.content);
119
+ }
120
+ group.toolMessageIndexes.forEach((absIdx, idx) => {
121
+ membership.set(absIdx, {
122
+ groupKey,
123
+ isFirst: idx === 0,
124
+ toolCount: group.toolMessageIndexes.length,
125
+ totalLines,
126
+ memberIndexes: group.toolMessageIndexes,
127
+ });
128
+ });
129
+ }
130
+ return membership;
131
+ }
132
+
133
+ /** Convenience wrapper: detect groups, then build the membership map in one call. */
134
+ export function computeToolGroupMembership(
135
+ messages: readonly Message[],
136
+ indexOffset = 0,
137
+ ): ReadonlyMap<number, ToolGroupMembership> {
138
+ return buildToolGroupMembership(detectToolGroups(messages, indexOffset), messages, indexOffset);
139
+ }
@@ -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,12 @@ 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
+ };
@@ -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;
@@ -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
  *
@@ -34,14 +34,31 @@ function toggleBlocks(typeFilter: string, collapsed: boolean, ctx: CommandContex
34
34
  let count = 0;
35
35
  for (let i = 0; i < blockRegistry.length; i++) {
36
36
  const block = blockRegistry[i];
37
+ // A folded tool-result group (type 'tool_group', see conversation-tool-groups.ts)
38
+ // is a run of tool results under one header, so '/expand tool'/'collapse tool'
39
+ // treats it the same as a plain 'tool' block — toggling the header's collapse
40
+ // key expands/collapses every member underneath it.
37
41
  const matchesType = typeFilter === 'all'
38
- || (typeFilter === 'tool' && block.type === 'tool')
42
+ || (typeFilter === 'tool' && (block.type === 'tool' || block.type === 'tool_group'))
39
43
  || (typeFilter === 'code' && block.type === 'code')
40
44
  || (typeFilter === 'thinking' && block.type === 'thinking');
41
45
  if (!matchesType) continue;
42
46
  const isCurrentlyCollapsed = ctx.session.conversationManager.isCollapsed(i);
43
47
  if (collapsed ? !isCurrentlyCollapsed : isCurrentlyCollapsed) {
44
48
  ctx.session.conversationManager.toggleCollapseAtLine(block.startLine);
49
+ // Expanding a folded tool-result group also expands every member's own
50
+ // collapse key in the same pass. A folded member pushes no BlockMeta of
51
+ // its own while the group is collapsed, so it never surfaces from this
52
+ // loop to be toggled individually — without this, '/expand tool' only
53
+ // opens the group header and each member still renders at whatever its
54
+ // own default collapse state is, needing a second '/expand tool' pass.
55
+ // '/collapse tool' needs no matching step: collapsing the group hides
56
+ // every member regardless of its own key.
57
+ if (!collapsed && block.type === 'tool_group' && block.groupMemberIndexes) {
58
+ for (const memberIdx of block.groupMemberIndexes) {
59
+ ctx.session.conversationManager.setCollapsed(`msg_${memberIdx}`, false);
60
+ }
61
+ }
45
62
  count++;
46
63
  }
47
64
  }
package/src/main.ts CHANGED
@@ -39,9 +39,10 @@ import { createSessionContinuityHintsBuilder } from './runtime/session-continuit
39
39
  import { resolveWebSurfaceUrl } from '@pellux/goodvibes-sdk/platform/runtime/feature-announcements';
40
40
  import { readLastSessionPointer, writeRecoveryFile } from '@/runtime/index.ts';
41
41
  import { handleBlockingShellInput, type PendingPermissionState } from './shell/blocking-input.ts';
42
- import { autoRestoreRecoverySession, createPersistRecoverySnapshot, createReopenRecoveryPanels, handleErrorAffordanceKey } from './shell/recovery-input-helpers.ts';
42
+ import { handleErrorAffordanceKey } from './shell/recovery-input-helpers.ts';
43
43
  import { wireShellUiOpeners } from './shell/ui-openers.ts';
44
44
  import { deriveComposerState } from './core/composer-state.ts';
45
+ import { resolveFoldedBookmarkLine } from './core/bookmark-navigation.ts';
45
46
  import { buildPersistedSessionContext, getReturnContextMode, maybeAssistReturnContextSummary } from '@/runtime/index.ts';
46
47
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
47
48
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
@@ -301,13 +302,14 @@ async function main() {
301
302
  const jumpToBookmark = (key: string) => {
302
303
  conversation.getDisplayBlocks();
303
304
  const block = conversation.getBlockRegistry().find((entry) => entry.collapseKey === key);
304
- if (!block) {
305
+ const line = block?.startLine ?? resolveFoldedBookmarkLine(conversation, key);
306
+ if (line === null) {
305
307
  systemMessageRouter.high(`[Bookmark] Not found: ${key}`);
306
308
  render();
307
309
  return;
308
310
  }
309
311
  scrollLocked = false;
310
- scrollTop = Math.max(0, block.startLine);
312
+ scrollTop = Math.max(0, line);
311
313
  render();
312
314
  };
313
315
 
@@ -764,20 +766,13 @@ async function main() {
764
766
  process.on('unhandledRejection', unhandledRejectionHandler);
765
767
  stdout.on('resize', resizeHandler);
766
768
 
767
- // --- Silent crash-recovery restore (no prompt/banner) ---
768
- // Restore the newest crash snapshot in place, replay any post-snapshot
769
- // journal turns, reopen its panels, and emit a single one-line receipt. The
770
- // SDK scopes both the load and the follow-up delete to that snapshot's own
771
- // session id, so a concurrent session's snapshot is never touched. Runs
772
- // before the initial render so the restored content shows immediately.
773
- autoRestoreRecoverySession({
774
- workingDirectory: workingDir,
775
- homeDirectory,
776
- conversation,
777
- persistSnapshot: createPersistRecoverySnapshot({ sessionManager: ctx.services.sessionManager, runtime, conversation }),
778
- reopenPanels: createReopenRecoveryPanels({ panelManager, render }),
779
- systemMessageRouter,
780
- });
769
+ // State restores happen ONLY when the user explicitly asks — a CLI flag
770
+ // (--continue/--resume/--fork), a slash command (/session resume,
771
+ // /checkpoints, /rewind), or the boot resume notice's advertised command.
772
+ // There is deliberately no unconditional auto-restore here: a bare launch
773
+ // never loads a saved conversation on its own (owner ruling). Any live
774
+ // recovery snapshot is surfaced (never applied) by the boot resume
775
+ // notice — see announceResumeState in runtime/resume-notice.ts.
781
776
 
782
777
  // Initial render
783
778
  conversation.rebuildHistory();
@@ -279,7 +279,6 @@ export const loadLastConversation = operations.loadLastConversation;
279
279
  export const loadRecoveryConversation = operations.loadRecoveryConversation;
280
280
  export const writeRecoveryFile = operations.writeRecoveryFile;
281
281
  export const deleteRecoveryFile = operations.deleteRecoveryFile;
282
- export const autoRestoreRecovery = operations.autoRestoreRecovery;
283
282
  export const checkRecoveryFile = operations.checkRecoveryFile;
284
283
  export const getRecoveryFilePath = operations.getRecoveryFilePath;
285
284
  export const getRecoveryDir = operations.getRecoveryDir;
@@ -27,9 +27,19 @@
27
27
  * - Every clause is independently gated on real data: a claim about
28
28
  * checkpoints/chain history is only made when that data is known; "no
29
29
  * chain history" means no chain clause is printed, not a fabricated one.
30
+ * - A live recovery snapshot (the SDK's checkRecoveryFile — a separate
31
+ * on-disk file from the session store, written every 60s while a session
32
+ * runs) is reported whenever one exists, but the specific
33
+ * `/session resume <id>` restore command is only advertised when that
34
+ * exact session id is ALSO loadable through the session manager
35
+ * (verified with a real load attempt, not assumed). A snapshot whose
36
+ * session never completed a full turn has no working command that
37
+ * reaches it yet — reported honestly without one, rather than
38
+ * advertising a command that would fail. This module never auto-applies
39
+ * the snapshot itself; restoring it is always the user's explicit call.
30
40
  */
31
41
 
32
- import { readLastSessionPointer } from '@/runtime/index.ts';
42
+ import { checkRecoveryFile, readLastSessionPointer } from '@/runtime/index.ts';
33
43
  import type { WrfcChain } from '@pellux/goodvibes-sdk/platform/agents';
34
44
  import type { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
35
45
  import type { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
@@ -90,6 +100,19 @@ export interface ResumeNoticeFacts {
90
100
  readonly lastChainOutcome: ChainOutcome | null;
91
101
  /** Whether /recall (memory) is wired up in this session. */
92
102
  readonly memoryAvailable: boolean;
103
+ /**
104
+ * A live recovery snapshot on disk (newer than the last clean save), if
105
+ * any — the SDK's checkRecoveryFile, written periodically while a session
106
+ * runs and left behind by one that never reached a clean save (a crash is
107
+ * one cause; a second TUI still running in the same workspace is another —
108
+ * this module has no liveness check, so it never claims a crash happened,
109
+ * only that a snapshot exists). `resumable` is true only when that exact
110
+ * session id is also loadable through the session manager, i.e.
111
+ * `/session resume <id>` genuinely reaches it; false means the snapshot
112
+ * exists but nothing reaches it yet (e.g. a crash during its very first
113
+ * turn, before any full save). Null when there is no live snapshot at all.
114
+ */
115
+ readonly recoverySnapshot: { readonly sessionId: string; readonly resumable: boolean } | null;
93
116
  }
94
117
 
95
118
  function plural(n: number, word: string): string {
@@ -107,8 +130,10 @@ export function buildResumeNotice(facts: ResumeNoticeFacts): string | null {
107
130
  const checkpointCount = facts.checkpointCount ?? 0;
108
131
  const hasCheckpoints = checkpointsKnown && checkpointCount > 0;
109
132
  const hasChainHistory = facts.lastChainOutcome !== null;
133
+ const recovery = facts.recoverySnapshot;
134
+ const hasRecovery = recovery !== null;
110
135
 
111
- if (!hasSession && !hasCheckpoints && !hasChainHistory) return null;
136
+ if (!hasSession && !hasCheckpoints && !hasChainHistory && !hasRecovery) return null;
112
137
 
113
138
  const summary: string[] = [];
114
139
  if (hasSession) summary.push(plural(facts.turnCount!, 'turn'));
@@ -117,6 +142,9 @@ export function buildResumeNotice(facts: ResumeNoticeFacts): string | null {
117
142
  // without one) — never guessed when the manager is unavailable.
118
143
  if (checkpointsKnown && (hasSession || hasCheckpoints)) summary.push(plural(checkpointCount, 'checkpoint'));
119
144
  if (hasChainHistory) summary.push(`last chain: ${facts.lastChainOutcome}`);
145
+ // Reported whenever a live snapshot exists, independent of whether a
146
+ // restore command can be advertised for it (see the hint below).
147
+ if (hasRecovery) summary.push('recovery snapshot found');
120
148
 
121
149
  const lead = hasSession ? 'Previous session found' : 'Workspace history found';
122
150
  let notice = `${lead}: ${summary.join(', ')}`;
@@ -126,6 +154,12 @@ export function buildResumeNotice(facts: ResumeNoticeFacts): string | null {
126
154
  if (hasSession) hints.push(`/session resume ${facts.lastSessionId} to continue`);
127
155
  if (hasCheckpoints) hints.push('/checkpoints to browse');
128
156
  if (facts.memoryAvailable) hints.push('/recall for memory');
157
+ // Only advertise the recovery snapshot's restore command when it is
158
+ // verified-resumable AND names a session other than the one already
159
+ // hinted above — otherwise this would just repeat the same command.
160
+ if (hasRecovery && recovery!.resumable && recovery!.sessionId !== facts.lastSessionId) {
161
+ hints.push(`/session resume ${recovery!.sessionId} to restore it`);
162
+ }
129
163
  if (hints.length > 0) notice += ` — ${hints.join(' · ')}`;
130
164
 
131
165
  return notice;
@@ -175,6 +209,36 @@ function readLastSessionTurns(deps: Pick<ResumeNoticeDeps, 'workingDirectory' |
175
209
  }
176
210
  }
177
211
 
212
+ /**
213
+ * Whether a live recovery snapshot exists, and whether its exact
214
+ * session id is also resumable through the real `/session resume <id>` path.
215
+ * A real load attempt (not a guess) decides resumability: `sessionManager`
216
+ * and `checkRecoveryFile`'s recovery directory are genuinely separate
217
+ * on-disk stores (see resume-notice.ts's header doc), so a snapshot whose
218
+ * session never completed a full turn will not be in the session store yet.
219
+ * Returns null when there is no live snapshot at all.
220
+ */
221
+ function readRecoverySnapshot(
222
+ deps: Pick<ResumeNoticeDeps, 'workingDirectory' | 'homeDirectory' | 'sessionManager'>,
223
+ ): { sessionId: string; resumable: boolean } | null {
224
+ // No surfaceRoot: the recovery file lives in the SHARED .goodvibes/recovery
225
+ // directory, the same way main.ts's writeRecoveryFile call and the SDK's
226
+ // own getContinuitySnapshot() read it — unlike the session store /
227
+ // last-session pointer above, which IS scoped to surfaceRoot 'tui'.
228
+ const info = checkRecoveryFile({
229
+ workingDirectory: deps.workingDirectory,
230
+ homeDirectory: deps.homeDirectory,
231
+ });
232
+ if (!info) return null;
233
+ let resumable = true;
234
+ try {
235
+ deps.sessionManager.load(info.sessionId);
236
+ } catch {
237
+ resumable = false;
238
+ }
239
+ return { sessionId: info.sessionId, resumable };
240
+ }
241
+
178
242
  async function readCheckpointCount(mgr: ResumeNoticeDeps['checkpointManager']): Promise<number | null> {
179
243
  if (!mgr) return null;
180
244
  try {
@@ -189,13 +253,18 @@ async function readCheckpointCount(mgr: ResumeNoticeDeps['checkpointManager']):
189
253
  /**
190
254
  * Gather real facts from disk/services and, if there is anything to report,
191
255
  * print ONE compact system message via `deps.router.high`. No-op (and no
192
- * message) when there is no prior session, no checkpoints, and no chain
193
- * history — a fresh working directory stays quiet.
256
+ * message) when there is no prior session, no checkpoints, no chain history,
257
+ * and no live recovery snapshot — a fresh working directory stays
258
+ * quiet. This is the ONLY thing a live recovery snapshot does at
259
+ * startup: it never mutates the conversation, never touches the journal, and
260
+ * never deletes the snapshot file — restoring it is always the user's
261
+ * explicit call (via the advertised /session resume <id>, when one exists).
194
262
  */
195
263
  export async function announceResumeState(deps: ResumeNoticeDeps): Promise<void> {
196
264
  const session = readLastSessionTurns(deps);
197
265
  const checkpointCount = await readCheckpointCount(deps.checkpointManager);
198
266
  const lastChain = mostRecentChain(deps.chainHistory);
267
+ const recoverySnapshot = readRecoverySnapshot(deps);
199
268
 
200
269
  const notice = buildResumeNotice({
201
270
  turnCount: session?.turnCount ?? null,
@@ -203,6 +272,7 @@ export async function announceResumeState(deps: ResumeNoticeDeps): Promise<void>
203
272
  checkpointCount,
204
273
  lastChainOutcome: lastChain ? describeChainOutcome(lastChain) : null,
205
274
  memoryAvailable: deps.memoryAvailable,
275
+ recoverySnapshot,
206
276
  });
207
277
 
208
278
  if (notice) deps.router.high(notice);
@@ -1,102 +1,17 @@
1
1
  /**
2
- * Helper factories for main()'s stdin fast-path and startup: the crash-recovery
3
- * snapshot persistence + panel-reopen callbacks, the SILENT auto-restore, and
4
- * the one-key error-retry affordance. Extracted from main.ts so the entrypoint
5
- * stays under the architecture line ceiling; main() wires these with its live
6
- * services.
2
+ * Helper factory for main()'s stdin fast-path: the one-key error-retry
3
+ * affordance. Extracted from main.ts so the entrypoint stays under the
4
+ * architecture line ceiling; main() wires this with its live services.
7
5
  *
8
- * Recovery is silent (owner ruling): there is no Ctrl+R prompt/banner and no
9
- * dismiss/preserve dance. At startup autoRestoreRecoverySession restores the
10
- * newest crash snapshot in place and emits a single one-line receipt. The old
11
- * `.preserved` sibling machinery is gone — a session's exit deletes only its
12
- * OWN snapshot (scoped by sessionId), so a concurrent session's snapshot is
13
- * never touched.
6
+ * The silent startup auto-restore that used to live here
7
+ * (autoRestoreRecoverySession, plus its createPersistRecoverySnapshot /
8
+ * createReopenRecoveryPanels factories) has been removed: state restores
9
+ * happen ONLY when the user explicitly asks (a CLI flag, a slash command, or
10
+ * a prompt), never automatically at bare launch (owner ruling). A live
11
+ * recovery snapshot is now surfaced, never applied, by the boot resume
12
+ * notice — see announceResumeState in runtime/resume-notice.ts.
14
13
  */
15
14
 
16
- import type { ConversationMessageSnapshot } from '../core/conversation.ts';
17
- import type { SessionSnapshot } from '@/runtime/index.ts';
18
- import { autoRestoreRecovery } from '@/runtime/index.ts';
19
- import { replayJournalForSession } from '../core/session-recovery.ts';
20
-
21
- export interface PersistRecoveryDeps {
22
- readonly sessionManager: {
23
- save(id: string, msgs: never[], meta: { title: string; model: string; provider: string; timestamp: number }): unknown;
24
- };
25
- readonly runtime: { readonly sessionId: string; readonly model: string; readonly provider: string };
26
- readonly conversation: { readonly title?: string | null };
27
- }
28
-
29
- /** Persist a replayed/restored snapshot through the session manager. */
30
- export function createPersistRecoverySnapshot(deps: PersistRecoveryDeps): (msgs: ConversationMessageSnapshot[]) => void {
31
- return (msgs) => void deps.sessionManager.save(deps.runtime.sessionId, msgs as never[], {
32
- title: deps.conversation.title ?? '',
33
- model: deps.runtime.model,
34
- provider: deps.runtime.provider,
35
- timestamp: Date.now(),
36
- });
37
- }
38
-
39
- export interface ReopenPanelsDeps {
40
- readonly panelManager: { open(id: string): void; show(): void };
41
- readonly render: () => void;
42
- }
43
-
44
- /** Reopen the panels recorded in a restored session's return context (capped at 4). */
45
- export function createReopenRecoveryPanels(deps: ReopenPanelsDeps): (snapshot: SessionSnapshot) => void {
46
- return (snapshot) => {
47
- for (const panelId of (snapshot.returnContext?.openPanels ?? []).slice(0, 4)) {
48
- try { deps.panelManager.open(panelId); } catch { /* unknown panel id */ }
49
- }
50
- if ((snapshot.returnContext?.openPanels?.length ?? 0) > 0) { deps.panelManager.show(); deps.render(); }
51
- };
52
- }
53
-
54
- export interface AutoRestoreRecoveryDeps {
55
- readonly workingDirectory: string;
56
- readonly homeDirectory: string;
57
- /** The live conversation to restore INTO (fromJSON applies the snapshot). */
58
- readonly conversation: {
59
- fromJSON(input: { messages: ConversationMessageSnapshot[]; title?: string; titleSource?: SessionSnapshot['titleSource'] }): void;
60
- };
61
- /** Persist the post-replay snapshot so the WAL gap is durably closed. */
62
- readonly persistSnapshot: (msgs: ConversationMessageSnapshot[]) => void;
63
- /** Reopen the restored session's panels. */
64
- readonly reopenPanels: (snapshot: SessionSnapshot) => void;
65
- /** Surface the one-line receipt (the only user-visible sign of a restore). */
66
- readonly systemMessageRouter: { high(message: string): void };
67
- }
68
-
69
- /**
70
- * Silent crash-recovery restore at startup — no prompt, no banner. Loads the
71
- * newest crash snapshot (the SDK scopes the load AND the follow-up delete to
72
- * that snapshot's own session id), applies it to the live conversation, replays
73
- * any journal turns written after the snapshot, reopens its panels, and emits
74
- * the SDK's one-line receipt. Returns true when a session was restored, false
75
- * when there was nothing to restore.
76
- */
77
- export function autoRestoreRecoverySession(deps: AutoRestoreRecoveryDeps): boolean {
78
- const result = autoRestoreRecovery({ workingDirectory: deps.workingDirectory, homeDirectory: deps.homeDirectory });
79
- if (!result) return false;
80
- const { snapshot, info, receipt } = result;
81
- deps.conversation.fromJSON({
82
- messages: snapshot.messages as ConversationMessageSnapshot[],
83
- title: snapshot.title,
84
- titleSource: snapshot.titleSource,
85
- });
86
- // Replay journal records that post-date the snapshot so turns written after
87
- // the last recovery-file write (but before the crash) are not dropped.
88
- replayJournalForSession({
89
- homeDirectory: deps.homeDirectory,
90
- sessionId: info.sessionId,
91
- snapshotTimestamp: info.timestamp ?? 0,
92
- conversation: deps.conversation as never,
93
- persistSnapshot: deps.persistSnapshot,
94
- });
95
- deps.reopenPanels(snapshot);
96
- deps.systemMessageRouter.high(`[Recovery] ${receipt}`);
97
- return true;
98
- }
99
-
100
15
  export interface ErrorAffordanceDeps {
101
16
  /** True when the failover retry context is armed (a retry is actually possible). */
102
17
  readonly retryArmed: boolean;
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.19.5';
9
+ let _version = '1.19.6';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  // Only trust a version read from OUR OWN package.json. A Bun single-file