@pellux/goodvibes-tui 1.19.4 → 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 +14 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/core/bookmark-navigation.ts +44 -0
- package/src/core/conversation-line-cache.ts +87 -19
- package/src/core/conversation-rendering.ts +118 -6
- package/src/core/conversation-tool-groups.ts +139 -0
- package/src/core/conversation-types.ts +21 -3
- package/src/core/conversation.ts +17 -0
- package/src/core/session-recovery.ts +3 -5
- package/src/input/commands/local-runtime.ts +18 -1
- package/src/main.ts +12 -17
- package/src/renderer/tool-result-summary.ts +11 -30
- package/src/runtime/index.ts +0 -1
- package/src/runtime/resume-notice.ts +74 -4
- package/src/shell/recovery-input-helpers.ts +10 -95
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,20 @@ 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
|
+
|
|
13
|
+
## [1.19.5] - 2026-07-18
|
|
14
|
+
|
|
15
|
+
### Changes
|
|
16
|
+
|
|
17
|
+
Fixed: command results no longer print the credential-scrub env-var name list ("withheld: ...") on every collapsed line — the dominant transcript noise for anyone with several API keys exported. The full detail remains in the expanded result payload.
|
|
18
|
+
Fixed: the encrypted secrets store can no longer be silently orphaned by key changes — key generation is race-safe, writes refuse a stale cached key (and restore a missing keyfile), and a mismatched store now reports exactly which key wrote it.
|
|
19
|
+
Updated: bundled platform runtime to 1.11.4.
|
|
20
|
+
|
|
7
21
|
## [1.19.4] - 2026-07-17
|
|
8
22
|
|
|
9
23
|
### Changes
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](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.
|
|
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",
|
|
@@ -104,8 +104,8 @@
|
|
|
104
104
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
105
105
|
"@ast-grep/napi": "^0.42.0",
|
|
106
106
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
107
|
-
"@pellux/goodvibes-sdk": "1.11.
|
|
108
|
-
"@pellux/goodvibes-terminal-shell": "1.11.
|
|
107
|
+
"@pellux/goodvibes-sdk": "1.11.4",
|
|
108
|
+
"@pellux/goodvibes-terminal-shell": "1.11.4",
|
|
109
109
|
"bash-language-server": "^5.6.0",
|
|
110
110
|
"fuse.js": "^7.1.0",
|
|
111
111
|
"graphql": "^16.13.2",
|
|
@@ -131,10 +131,10 @@
|
|
|
131
131
|
"typescript": "^5.9.3"
|
|
132
132
|
},
|
|
133
133
|
"optionalDependencies": {
|
|
134
|
-
"@pellux/goodvibes-tui-linux-x64": "1.19.
|
|
135
|
-
"@pellux/goodvibes-tui-linux-arm64": "1.19.
|
|
136
|
-
"@pellux/goodvibes-tui-darwin-x64": "1.19.
|
|
137
|
-
"@pellux/goodvibes-tui-darwin-arm64": "1.19.
|
|
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
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
579
|
+
renderConversationUserMessage(groupedContext, message, width, absoluteIdx);
|
|
470
580
|
} else if (message.role === 'assistant') {
|
|
471
|
-
renderConversationAssistantMessage(
|
|
581
|
+
renderConversationAssistantMessage(groupedContext, message, width, lineNumberMode, collapseThreshold, absoluteIdx);
|
|
472
582
|
} else if (message.role === 'system') {
|
|
473
|
-
renderConversationSystemMessage(
|
|
583
|
+
renderConversationSystemMessage(groupedContext, message, width, absoluteIdx);
|
|
474
584
|
} else if (message.role === 'tool') {
|
|
475
|
-
renderConversationToolMessage(
|
|
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
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* TUI extends the SDK BlockMeta with rendering position fields, plus a
|
|
16
|
+
* TUI-only block type: 'tool_group' is the synthetic header block that folds
|
|
17
|
+
* a run of >=2 consecutive tool-result messages under one collapsible header
|
|
18
|
+
* (see conversation-tool-groups.ts). Defined as an intersection rather than
|
|
19
|
+
* `interface X extends SdkBlockMeta` because TypeScript requires an extending
|
|
20
|
+
* interface's members to be subtypes of the base interface's — widening the
|
|
21
|
+
* `type` union that way is a compile error. Omit + intersection adds the new
|
|
22
|
+
* variant without touching the SDK's published type.
|
|
23
|
+
*/
|
|
24
|
+
export type BlockMeta = Omit<SdkBlockMeta, 'type'> & {
|
|
25
|
+
type: SdkBlockMeta['type'] | 'tool_group';
|
|
16
26
|
/** Index of this block (increments per renderable block). */
|
|
17
27
|
blockIndex: number;
|
|
18
28
|
/** First rendered line index in the history buffer. */
|
|
@@ -21,4 +31,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
|
+
};
|
package/src/core/conversation.ts
CHANGED
|
@@ -644,6 +644,23 @@ export class ConversationManager extends SdkConversationManager {
|
|
|
644
644
|
return nearest.blockIndex;
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
+
/** First rendered line for message `absoluteIdx` (undefined if never
|
|
648
|
+
* rendered). For a folded tool-group member this is the group's own
|
|
649
|
+
* header line, not the following message's position — see
|
|
650
|
+
* messageLineRegistry's doc. Flushes history if dirty. */
|
|
651
|
+
public getMessageLine(absoluteIdx: number): number | undefined {
|
|
652
|
+
this.flushHistory();
|
|
653
|
+
return this.messageLineRegistry[absoluteIdx];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Set a collapseKey's state directly, bypassing block lookup — needed for
|
|
657
|
+
* a key with no BlockMeta yet (a folded tool-group member's own
|
|
658
|
+
* `msg_<idx>` key only becomes a real block once its group expands). */
|
|
659
|
+
public setCollapsed(collapseKey: string, collapsed: boolean): void {
|
|
660
|
+
this.collapseState.set(collapseKey, collapsed);
|
|
661
|
+
this.markDirty();
|
|
662
|
+
}
|
|
663
|
+
|
|
647
664
|
/** Returns a read-only view of the block registry for external consumers. */
|
|
648
665
|
public getBlockRegistry(): readonly BlockMeta[] {
|
|
649
666
|
return this.blockRegistry;
|
|
@@ -8,14 +8,12 @@
|
|
|
8
8
|
* it replays those records onto the live conversation and writes a fresh
|
|
9
9
|
* snapshot so the gap is permanently closed.
|
|
10
10
|
*
|
|
11
|
-
* Seams (
|
|
12
|
-
*
|
|
11
|
+
* Seams (both must call replayJournalForSession)
|
|
12
|
+
* ───────────────────────────────────────────────
|
|
13
13
|
* 1. CLI / command resume — session-workflow.ts, after `fromJSON` +
|
|
14
14
|
* `rebuildHistory` complete. Handles --continue, --resume, /session resume,
|
|
15
15
|
* and --fork.
|
|
16
|
-
* 2.
|
|
17
|
-
* in the Ctrl+R branch. Handles SIGKILL-era recovery files.
|
|
18
|
-
* 3. In-TUI panel resume — bootstrap-hook-bridge.ts
|
|
16
|
+
* 2. In-TUI panel resume — bootstrap-hook-bridge.ts
|
|
19
17
|
* `createResumeSessionHandler`, after `options.runtime.sessionId` is
|
|
20
18
|
* assigned. Handles the session browser / panel-driven resume.
|
|
21
19
|
*
|
|
@@ -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 {
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
|
|
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();
|
|
@@ -93,21 +93,12 @@ function summarizeEdit(obj: Record<string, unknown>): string | null {
|
|
|
93
93
|
return `${verb} ${applied} edit${applied === 1 ? '' : 's'}${target}${failText}`;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
/** Compact "withheld: NAME, NAME, …" suffix, capped so a long scrub list doesn't dominate the collapsed line. */
|
|
104
|
-
function withheldEnvSuffix(names: readonly string[]): string {
|
|
105
|
-
if (names.length === 0) return '';
|
|
106
|
-
const MAX_SHOWN = 3;
|
|
107
|
-
const shown = names.slice(0, MAX_SHOWN).join(', ');
|
|
108
|
-
const more = names.length > MAX_SHOWN ? `, +${names.length - MAX_SHOWN} more` : '';
|
|
109
|
-
return ` · withheld: ${shown}${more}`;
|
|
110
|
-
}
|
|
96
|
+
// The credential scrub's withheld_env report stays OUT of the collapsed line
|
|
97
|
+
// entirely. It withholds the same standing set of names on every spawn, so any
|
|
98
|
+
// per-result rendering repeats identical env-var noise all session long — the
|
|
99
|
+
// dominant transcript spam for anyone with several keys exported. The report
|
|
100
|
+
// only matters when a command fails because a needed credential was scrubbed,
|
|
101
|
+
// and for that case the full name list is one expand away in the raw payload.
|
|
111
102
|
|
|
112
103
|
/**
|
|
113
104
|
* Compact per-command exec-sandbox suffix: `sandboxed` fields are present
|
|
@@ -141,17 +132,16 @@ function summarizeExecOne(obj: Record<string, unknown>): string | null {
|
|
|
141
132
|
const exit = obj.exit_code;
|
|
142
133
|
const dur = asNumber(obj.duration_ms);
|
|
143
134
|
const durText = dur !== undefined ? ` · ${formatDuration(dur)}` : '';
|
|
144
|
-
const withheldText = withheldEnvSuffix(withheldEnvNames(obj));
|
|
145
135
|
const sandboxText = sandboxSuffix(obj);
|
|
146
|
-
if (obj.timed_out === true) return `timed out${durText}${
|
|
147
|
-
if (obj.cancelled === true) return `cancelled${durText}${
|
|
136
|
+
if (obj.timed_out === true) return `timed out${durText}${sandboxText}`;
|
|
137
|
+
if (obj.cancelled === true) return `cancelled${durText}${sandboxText}`;
|
|
148
138
|
const stdout = typeof obj.stdout === 'string' ? obj.stdout : '';
|
|
149
139
|
const lineCount = stdout.length === 0
|
|
150
140
|
? 0
|
|
151
141
|
: stdout.replace(/\n$/, '').split('\n').length;
|
|
152
142
|
const lineText = lineCount > 0 ? ` · ${lineCount} line${lineCount === 1 ? '' : 's'}` : '';
|
|
153
143
|
const code = typeof exit === 'number' ? String(exit) : '?';
|
|
154
|
-
return `exit ${code}${durText}${lineText}${
|
|
144
|
+
return `exit ${code}${durText}${lineText}${sandboxText}`;
|
|
155
145
|
}
|
|
156
146
|
|
|
157
147
|
/** exec: single { cmd, exit_code, ... } or multi { commands: [...], total }. */
|
|
@@ -161,15 +151,6 @@ function summarizeExec(obj: Record<string, unknown>): string | null {
|
|
|
161
151
|
const failed = obj.commands.filter(
|
|
162
152
|
(c) => c && typeof c === 'object' && (c as Record<string, unknown>).success === false,
|
|
163
153
|
).length;
|
|
164
|
-
// Union of withheld names across every command in the batch — the scrub
|
|
165
|
-
// runs per-spawn, so different commands can withhold different names.
|
|
166
|
-
const withheldAcrossCommands = new Set<string>();
|
|
167
|
-
for (const c of obj.commands) {
|
|
168
|
-
if (c && typeof c === 'object') {
|
|
169
|
-
for (const name of withheldEnvNames(c as Record<string, unknown>)) withheldAcrossCommands.add(name);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const withheldText = withheldEnvSuffix([...withheldAcrossCommands]);
|
|
173
154
|
// Count sandboxed commands across the batch — a compact "N sandboxed" beats
|
|
174
155
|
// repeating each command's full boundary detail on the collapsed line.
|
|
175
156
|
const sandboxedCount = obj.commands.filter(
|
|
@@ -177,8 +158,8 @@ function summarizeExec(obj: Record<string, unknown>): string | null {
|
|
|
177
158
|
).length;
|
|
178
159
|
const sandboxText = sandboxedCount > 0 ? ` · ${sandboxedCount} sandboxed` : '';
|
|
179
160
|
return failed > 0
|
|
180
|
-
? `${total} commands · ${failed} failed${
|
|
181
|
-
: `${total} commands · all ok${
|
|
161
|
+
? `${total} commands · ${failed} failed${sandboxText}`
|
|
162
|
+
: `${total} commands · all ok${sandboxText}`;
|
|
182
163
|
}
|
|
183
164
|
if ('exit_code' in obj || 'timed_out' in obj || 'cancelled' in obj) {
|
|
184
165
|
return summarizeExecOne(obj);
|
package/src/runtime/index.ts
CHANGED
|
@@ -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,
|
|
193
|
-
*
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* never
|
|
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.
|
|
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
|