@pellux/goodvibes-tui 1.17.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +4596 -1732
- package/package.json +7 -7
- package/src/cli/management-commands.ts +82 -15
- package/src/cli/management.ts +0 -2
- package/src/core/consolidation-receipt.ts +41 -0
- package/src/core/conversation-rendering.ts +14 -5
- package/src/core/memory-provenance.ts +67 -0
- package/src/core/pairing-devices.ts +49 -0
- package/src/core/pairing-handoff.ts +101 -0
- package/src/core/pairing-offers.ts +83 -0
- package/src/core/pairing-origin.ts +78 -0
- package/src/core/pairing-tailscale-gateway.ts +56 -0
- package/src/core/power-chip-source.ts +91 -0
- package/src/core/power-status.ts +95 -0
- package/src/core/stable-host.ts +171 -0
- package/src/core/stream-event-wiring.ts +21 -0
- package/src/core/turn-budget-outcome.ts +60 -0
- package/src/core/turn-cancellation.ts +39 -0
- package/src/core/turn-event-wiring.ts +6 -0
- package/src/daemon/cli.ts +47 -56
- package/src/input/command-registry.ts +21 -0
- package/src/input/commands/acp-runtime.ts +72 -0
- package/src/input/commands/devices-runtime.ts +109 -0
- package/src/input/commands/git-runtime.ts +28 -4
- package/src/input/commands/graph-runtime.ts +45 -0
- package/src/input/commands/operator-rpc.ts +32 -11
- package/src/input/commands/power-runtime.ts +56 -0
- package/src/input/commands/qrcode-runtime.ts +14 -19
- package/src/input/commands/queue-runtime.ts +70 -0
- package/src/input/commands/workstream-attempts.ts +6 -1
- package/src/input/commands/workstream-runtime.ts +44 -25
- package/src/input/commands/worktree-runtime.ts +35 -8
- package/src/input/commands.ts +10 -0
- package/src/input/config-modal-types.ts +11 -0
- package/src/input/config-modal.ts +65 -2
- package/src/input/handler-shortcuts.ts +23 -0
- package/src/input/keybindings.ts +19 -1
- package/src/input/settings-modal-data.ts +31 -37
- package/src/input/settings-modal-types.ts +5 -2
- package/src/main.ts +42 -42
- package/src/panels/builtin/operations.ts +54 -1
- package/src/panels/builtin/shared.ts +12 -0
- package/src/panels/builtin-modals.ts +49 -32
- package/src/panels/diff-panel.ts +34 -1
- package/src/panels/fleet-acts.ts +446 -0
- package/src/panels/fleet-gateway.ts +117 -0
- package/src/panels/fleet-observed-render.ts +128 -0
- package/src/panels/fleet-panel-format.ts +120 -4
- package/src/panels/fleet-panel-worktree-detail.ts +14 -0
- package/src/panels/fleet-panel.ts +60 -44
- package/src/panels/fleet-read-model.ts +34 -3
- package/src/panels/fleet-spawn.ts +266 -0
- package/src/panels/fleet-stop.ts +38 -2
- package/src/panels/fleet-tabs.ts +2 -2
- package/src/panels/memory-consolidation-gateway.ts +94 -0
- package/src/panels/modals/devices-modal.ts +161 -0
- package/src/panels/modals/memory-modal.ts +149 -5
- package/src/panels/modals/pairing-modal.ts +198 -68
- package/src/panels/modals/security-modal.ts +6 -0
- package/src/panels/workstream-graph-render.ts +123 -0
- package/src/renderer/composer-fragments.ts +69 -0
- package/src/renderer/settings-modal-helpers.ts +9 -0
- package/src/renderer/settings-modal.ts +4 -2
- package/src/renderer/shell-surface.ts +3 -0
- package/src/renderer/tool-call.ts +12 -6
- package/src/renderer/ui-factory.ts +32 -0
- package/src/runtime/bootstrap-command-context.ts +3 -1
- package/src/runtime/bootstrap-command-parts.ts +3 -1
- package/src/runtime/bootstrap-core.ts +6 -6
- package/src/runtime/bootstrap-shell.ts +7 -0
- package/src/runtime/durability-services.ts +36 -1
- package/src/runtime/fleet-services.ts +41 -0
- package/src/runtime/idle-power-services.ts +85 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/interaction-seams.ts +109 -0
- package/src/runtime/power-keepawake-remote.ts +75 -0
- package/src/runtime/process-lifecycle.ts +3 -1
- package/src/runtime/services.ts +38 -37
- package/src/runtime/ui-services.ts +3 -0
- package/src/shell/blocking-input.ts +21 -141
- package/src/shell/recovery-input-helpers.ts +53 -166
- package/src/version.ts +1 -1
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
import type { ConversationManager } from '../core/conversation';
|
|
2
1
|
import type { PermissionRequest, RememberTier } from '@pellux/goodvibes-sdk/platform/permissions';
|
|
3
|
-
import type { SessionSnapshot } from '@/runtime/index.ts';
|
|
4
|
-
import type { SystemMessageRouter } from '../core/system-message-router.ts';
|
|
5
|
-
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
6
|
-
import { replayJournalForSession } from '../core/session-recovery.ts';
|
|
7
2
|
import { applyHunkKey, buildModifiedEditArgs, type HunkSelectionState } from '../permissions/hunk-selection.ts';
|
|
8
3
|
|
|
9
4
|
export type PendingPermissionState = PermissionRequest & {
|
|
@@ -43,47 +38,8 @@ export const APPROVAL_INPUT_DEBOUNCE_MS = 350;
|
|
|
43
38
|
export type BlockingInputHandlerOptions = {
|
|
44
39
|
data: string;
|
|
45
40
|
pendingPermission: PendingPermissionState | null;
|
|
46
|
-
recoveryPending: boolean;
|
|
47
41
|
abortTurn: () => void;
|
|
48
|
-
conversation: ConversationManager;
|
|
49
|
-
systemMessageRouter: SystemMessageRouter;
|
|
50
42
|
render: () => void;
|
|
51
|
-
loadRecoveryConversation: () => SessionSnapshot | null;
|
|
52
|
-
deleteRecoveryFile: () => void;
|
|
53
|
-
/**
|
|
54
|
-
* Copy the live recovery.jsonl aside to a preserved sibling so the dismiss
|
|
55
|
-
* message's promise ("still on disk") survives this session's later 60s
|
|
56
|
-
* autosaves, which would otherwise overwrite the shared recovery file with
|
|
57
|
-
* the CURRENT (post-dismiss) session's state (W3 Finding 3). Optional so
|
|
58
|
-
* every pre-existing test/caller that doesn't care about preservation is
|
|
59
|
-
* unaffected; when omitted, dismiss behaves exactly as before.
|
|
60
|
-
*/
|
|
61
|
-
preserveRecoveryFile?: () => { readonly preserved: boolean; readonly replacedPrevious: boolean };
|
|
62
|
-
/**
|
|
63
|
-
* Absolute home directory used to locate the transcript journal for this
|
|
64
|
-
* recovery session. Required for journal replay on Ctrl+R restore.
|
|
65
|
-
*/
|
|
66
|
-
homeDirectory: string;
|
|
67
|
-
/**
|
|
68
|
-
* The session ID that the recovery file belongs to. Required for journal
|
|
69
|
-
* replay so the correct journal path can be resolved.
|
|
70
|
-
*/
|
|
71
|
-
sessionId: string;
|
|
72
|
-
/**
|
|
73
|
-
* Persist the post-replay snapshot so the WAL gap is durably closed.
|
|
74
|
-
* Called with the replayed message list. Best-effort — failures are swallowed
|
|
75
|
-
* inside replayJournalForSession.
|
|
76
|
-
*/
|
|
77
|
-
persistSnapshot: (messages: ConversationMessageSnapshot[]) => void;
|
|
78
|
-
/**
|
|
79
|
-
* Optional callback invoked after Ctrl+R restore to reopen panels captured in
|
|
80
|
-
* the recovery snapshot's returnContext. When provided (as wired in main.ts),
|
|
81
|
-
* the callback iterates snapshot.returnContext.openPanels and calls
|
|
82
|
-
* panelManager.open() for each entry, then panelManager.show() + render() to
|
|
83
|
-
* restore the panel posture from the recovered session. When omitted, panel
|
|
84
|
-
* posture is not restored.
|
|
85
|
-
*/
|
|
86
|
-
reopenPanels?: (snapshot: SessionSnapshot) => void;
|
|
87
43
|
/** Injectable clock for the approval-input debounce; defaults to Date.now(). Tests only. */
|
|
88
44
|
now?: number;
|
|
89
45
|
};
|
|
@@ -91,7 +47,6 @@ export type BlockingInputHandlerOptions = {
|
|
|
91
47
|
export type BlockingInputHandlerResult = {
|
|
92
48
|
handled: boolean;
|
|
93
49
|
pendingPermission: PendingPermissionState | null;
|
|
94
|
-
recoveryPending: boolean;
|
|
95
50
|
};
|
|
96
51
|
|
|
97
52
|
export function handleBlockingShellInput(
|
|
@@ -100,18 +55,8 @@ export function handleBlockingShellInput(
|
|
|
100
55
|
const {
|
|
101
56
|
data,
|
|
102
57
|
pendingPermission,
|
|
103
|
-
recoveryPending,
|
|
104
58
|
abortTurn,
|
|
105
|
-
conversation,
|
|
106
|
-
systemMessageRouter,
|
|
107
59
|
render,
|
|
108
|
-
loadRecoveryConversation,
|
|
109
|
-
deleteRecoveryFile,
|
|
110
|
-
preserveRecoveryFile,
|
|
111
|
-
homeDirectory,
|
|
112
|
-
sessionId,
|
|
113
|
-
persistSnapshot,
|
|
114
|
-
reopenPanels,
|
|
115
60
|
} = options;
|
|
116
61
|
|
|
117
62
|
if (pendingPermission) {
|
|
@@ -124,7 +69,7 @@ export function handleBlockingShellInput(
|
|
|
124
69
|
const now = options.now ?? Date.now();
|
|
125
70
|
if (req.openedAt !== undefined && now - req.openedAt < APPROVAL_INPUT_DEBOUNCE_MS) {
|
|
126
71
|
render();
|
|
127
|
-
return { handled: true, pendingPermission
|
|
72
|
+
return { handled: true, pendingPermission };
|
|
128
73
|
}
|
|
129
74
|
|
|
130
75
|
if (req.hunkState) {
|
|
@@ -132,16 +77,16 @@ export function handleBlockingShellInput(
|
|
|
132
77
|
if (commit === 'apply') {
|
|
133
78
|
req.resolve(true, false, buildModifiedEditArgs(req, state));
|
|
134
79
|
render();
|
|
135
|
-
return { handled: true, pendingPermission: null
|
|
80
|
+
return { handled: true, pendingPermission: null };
|
|
136
81
|
}
|
|
137
82
|
if (commit === 'cancel') {
|
|
138
83
|
req.resolve(false, false);
|
|
139
84
|
abortTurn();
|
|
140
85
|
render();
|
|
141
|
-
return { handled: true, pendingPermission: null
|
|
86
|
+
return { handled: true, pendingPermission: null };
|
|
142
87
|
}
|
|
143
88
|
render();
|
|
144
|
-
return { handled: true, pendingPermission: { ...req, hunkState: state }
|
|
89
|
+
return { handled: true, pendingPermission: { ...req, hunkState: state } };
|
|
145
90
|
}
|
|
146
91
|
|
|
147
92
|
// Ctrl+C is ALWAYS the hard abort: deny and kill the turn, in every mode.
|
|
@@ -149,7 +94,7 @@ export function handleBlockingShellInput(
|
|
|
149
94
|
req.resolve(false, false);
|
|
150
95
|
abortTurn();
|
|
151
96
|
render();
|
|
152
|
-
return { handled: true, pendingPermission: null
|
|
97
|
+
return { handled: true, pendingPermission: null };
|
|
153
98
|
}
|
|
154
99
|
|
|
155
100
|
// Typed-reply mode: deny-with-reason (started by typing on any card) or
|
|
@@ -168,7 +113,7 @@ export function handleBlockingShellInput(
|
|
|
168
113
|
req.resolve(false, false, undefined, { reason: buffer });
|
|
169
114
|
}
|
|
170
115
|
render();
|
|
171
|
-
return { handled: true, pendingPermission: null
|
|
116
|
+
return { handled: true, pendingPermission: null };
|
|
172
117
|
}
|
|
173
118
|
if (data === '\x1b') {
|
|
174
119
|
if (req.replyMode === 'exec-answer') {
|
|
@@ -176,26 +121,26 @@ export function handleBlockingShellInput(
|
|
|
176
121
|
// declines the prompt (the run gets the honest unanswered result).
|
|
177
122
|
if (buffer.length > 0) {
|
|
178
123
|
render();
|
|
179
|
-
return { handled: true, pendingPermission: { ...req, replyBuffer: '' }
|
|
124
|
+
return { handled: true, pendingPermission: { ...req, replyBuffer: '' } };
|
|
180
125
|
}
|
|
181
126
|
req.resolve(false, false);
|
|
182
127
|
render();
|
|
183
|
-
return { handled: true, pendingPermission: null
|
|
128
|
+
return { handled: true, pendingPermission: null };
|
|
184
129
|
}
|
|
185
130
|
// Esc leaves deny-reason mode back to the plain card.
|
|
186
131
|
render();
|
|
187
|
-
return { handled: true, pendingPermission: { ...req, replyMode: undefined, replyBuffer: undefined }
|
|
132
|
+
return { handled: true, pendingPermission: { ...req, replyMode: undefined, replyBuffer: undefined } };
|
|
188
133
|
}
|
|
189
134
|
if (data === '\x7f' || data === '\b') {
|
|
190
135
|
render();
|
|
191
|
-
return { handled: true, pendingPermission: { ...req, replyBuffer: buffer.slice(0, -1) }
|
|
136
|
+
return { handled: true, pendingPermission: { ...req, replyBuffer: buffer.slice(0, -1) } };
|
|
192
137
|
}
|
|
193
138
|
if (data.length >= 1 && !data.startsWith('\x1b') && data >= ' ') {
|
|
194
139
|
render();
|
|
195
|
-
return { handled: true, pendingPermission: { ...req, replyBuffer: buffer + data }
|
|
140
|
+
return { handled: true, pendingPermission: { ...req, replyBuffer: buffer + data } };
|
|
196
141
|
}
|
|
197
142
|
render();
|
|
198
|
-
return { handled: true, pendingPermission
|
|
143
|
+
return { handled: true, pendingPermission };
|
|
199
144
|
}
|
|
200
145
|
|
|
201
146
|
// Scroll, mouse, PageUp/Down, arrow, and panel-navigation keys — plus a
|
|
@@ -204,7 +149,7 @@ export function handleBlockingShellInput(
|
|
|
204
149
|
// request stays pending (answer it with y/n or a remember tier when ready);
|
|
205
150
|
// Ctrl+C above is still the hard abort, and 'n' still denies.
|
|
206
151
|
if (data.startsWith('\x1b')) {
|
|
207
|
-
return { handled: false, pendingPermission
|
|
152
|
+
return { handled: false, pendingPermission };
|
|
208
153
|
}
|
|
209
154
|
|
|
210
155
|
const key = data.toLowerCase().trim();
|
|
@@ -212,14 +157,14 @@ export function handleBlockingShellInput(
|
|
|
212
157
|
if (key === 'y') {
|
|
213
158
|
req.resolve(true, false);
|
|
214
159
|
render();
|
|
215
|
-
return { handled: true, pendingPermission: null
|
|
160
|
+
return { handled: true, pendingPermission: null };
|
|
216
161
|
}
|
|
217
162
|
|
|
218
163
|
if (key === 'a') {
|
|
219
164
|
// Legacy always-this-session choice — the 'session' remember tier.
|
|
220
165
|
req.resolve(true, true, undefined, { rememberTier: 'session' });
|
|
221
166
|
render();
|
|
222
|
-
return { handled: true, pendingPermission: null
|
|
167
|
+
return { handled: true, pendingPermission: null };
|
|
223
168
|
}
|
|
224
169
|
|
|
225
170
|
// Numbered remember tiers ([1]..[N], most specific first) from the SDK's
|
|
@@ -230,10 +175,10 @@ export function handleBlockingShellInput(
|
|
|
230
175
|
if (option) {
|
|
231
176
|
req.resolve(true, option.tier === 'session', undefined, { rememberTier: option.tier });
|
|
232
177
|
render();
|
|
233
|
-
return { handled: true, pendingPermission: null
|
|
178
|
+
return { handled: true, pendingPermission: null };
|
|
234
179
|
}
|
|
235
180
|
render();
|
|
236
|
-
return { handled: true, pendingPermission
|
|
181
|
+
return { handled: true, pendingPermission };
|
|
237
182
|
}
|
|
238
183
|
|
|
239
184
|
if (key === 'n') {
|
|
@@ -242,7 +187,7 @@ export function handleBlockingShellInput(
|
|
|
242
187
|
// Esc drops focus (handled above) rather than denying.
|
|
243
188
|
req.resolve(false, false);
|
|
244
189
|
render();
|
|
245
|
-
return { handled: true, pendingPermission: null
|
|
190
|
+
return { handled: true, pendingPermission: null };
|
|
246
191
|
}
|
|
247
192
|
|
|
248
193
|
if (key === 'd') {
|
|
@@ -251,7 +196,6 @@ export function handleBlockingShellInput(
|
|
|
251
196
|
return {
|
|
252
197
|
handled: true,
|
|
253
198
|
pendingPermission: { ...req, detailsExpanded: !req.detailsExpanded },
|
|
254
|
-
recoveryPending,
|
|
255
199
|
};
|
|
256
200
|
}
|
|
257
201
|
|
|
@@ -259,76 +203,12 @@ export function handleBlockingShellInput(
|
|
|
259
203
|
// becomes the denial feedback, seeded with this first character.
|
|
260
204
|
if (data.length === 1 && data >= ' ') {
|
|
261
205
|
render();
|
|
262
|
-
return { handled: true, pendingPermission: { ...req, replyMode: 'deny-reason', replyBuffer: data }
|
|
206
|
+
return { handled: true, pendingPermission: { ...req, replyMode: 'deny-reason', replyBuffer: data } };
|
|
263
207
|
}
|
|
264
208
|
|
|
265
209
|
render();
|
|
266
|
-
return { handled: true, pendingPermission
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (recoveryPending) {
|
|
270
|
-
if (data === '\x12') {
|
|
271
|
-
const recovery = loadRecoveryConversation();
|
|
272
|
-
if (recovery) {
|
|
273
|
-
conversation.fromJSON({
|
|
274
|
-
messages: recovery.messages as Parameters<typeof conversation.fromJSON>[0]['messages'],
|
|
275
|
-
title: recovery.title,
|
|
276
|
-
titleSource: recovery.titleSource,
|
|
277
|
-
});
|
|
278
|
-
// Replay journal records that post-date the recovery snapshot so turns
|
|
279
|
-
// written after the last recovery-file write (but before SIGKILL) are
|
|
280
|
-
// not silently dropped. snapshotTimestamp=0 when timestamp is absent so
|
|
281
|
-
// all journal records are replayed — safer than dropping.
|
|
282
|
-
replayJournalForSession({
|
|
283
|
-
homeDirectory,
|
|
284
|
-
sessionId,
|
|
285
|
-
snapshotTimestamp: recovery.timestamp ?? 0,
|
|
286
|
-
conversation,
|
|
287
|
-
persistSnapshot,
|
|
288
|
-
});
|
|
289
|
-
reopenPanels?.(recovery);
|
|
290
|
-
systemMessageRouter.high('[Recovery] Session restored.');
|
|
291
|
-
deleteRecoveryFile();
|
|
292
|
-
} else {
|
|
293
|
-
systemMessageRouter.high('[Recovery] Failed to restore saved data.');
|
|
294
|
-
}
|
|
295
|
-
render();
|
|
296
|
-
return { handled: true, pendingPermission: null, recoveryPending: false };
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (data === '\x1b' || data === '\x03') {
|
|
300
|
-
systemMessageRouter.high('[Recovery] Discarded recovery data.');
|
|
301
|
-
deleteRecoveryFile();
|
|
302
|
-
render();
|
|
303
|
-
return { handled: true, pendingPermission: null, recoveryPending: false };
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// Any other key demonstrates the user's intent to ignore the banner and
|
|
307
|
-
// keep working — the prompt's own text invites exactly this ("start
|
|
308
|
-
// typing to ignore it"). Previously this branch re-posted the same
|
|
309
|
-
// '[Recovery] Ctrl+R to restore...' line on EVERY such key and never
|
|
310
|
-
// cleared recoveryPending, so a user who took that invitation got a
|
|
311
|
-
// fresh [Recovery] line injected into the transcript around every
|
|
312
|
-
// character they typed, forever. Dismiss ONCE instead: clear
|
|
313
|
-
// recoveryPending so it stops re-asserting, but do NOT delete the
|
|
314
|
-
// recovery file — dismiss is not discard, and the file remains
|
|
315
|
-
// restorable by the automatic recovery check on the next launch.
|
|
316
|
-
//
|
|
317
|
-
// W3 Finding 3: the promise below ("still on disk; you will be asked
|
|
318
|
-
// again") used to go false silently — main.ts's 60s autosave overwrites
|
|
319
|
-
// the single shared recovery.jsonl with the CURRENT session's state
|
|
320
|
-
// within a minute. preserveRecoveryFile (when wired) copies it aside
|
|
321
|
-
// NOW, while it still holds the dismissed session's data, so the
|
|
322
|
-
// promise stays true. If an earlier dismiss's preserved snapshot gets
|
|
323
|
-
// replaced by this one, say so honestly instead of silently discarding it.
|
|
324
|
-
const preserveResult = preserveRecoveryFile?.();
|
|
325
|
-
if (preserveResult?.replacedPrevious) {
|
|
326
|
-
systemMessageRouter.low('[Recovery] Replacing the previously preserved (unrestored) snapshot with this one.');
|
|
327
|
-
}
|
|
328
|
-
systemMessageRouter.high('[Recovery] Dismissed — the unsaved session is still on disk; you will be asked again next time GoodVibes starts here.');
|
|
329
|
-
render();
|
|
330
|
-
return { handled: false, pendingPermission, recoveryPending: false };
|
|
210
|
+
return { handled: true, pendingPermission };
|
|
331
211
|
}
|
|
332
212
|
|
|
333
|
-
return { handled: false, pendingPermission
|
|
213
|
+
return { handled: false, pendingPermission };
|
|
334
214
|
}
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Helper factories for main()'s stdin fast-path: the
|
|
3
|
-
* persistence
|
|
4
|
-
* affordance. Extracted from main.ts so the entrypoint
|
|
5
|
-
* architecture line ceiling; main() wires these with its live
|
|
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.
|
|
7
|
+
*
|
|
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
14
|
*/
|
|
7
15
|
|
|
8
|
-
import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
9
16
|
import type { ConversationMessageSnapshot } from '../core/conversation.ts';
|
|
10
|
-
import type {
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
deleteRecoveryFile as deleteLiveRecoveryFile,
|
|
14
|
-
getRecoveryFilePath,
|
|
15
|
-
loadRecoveryConversation as loadLiveRecoveryConversation,
|
|
16
|
-
} from '@/runtime/index.ts';
|
|
17
|
+
import type { SessionSnapshot } from '@/runtime/index.ts';
|
|
18
|
+
import { autoRestoreRecovery } from '@/runtime/index.ts';
|
|
19
|
+
import { replayJournalForSession } from '../core/session-recovery.ts';
|
|
17
20
|
|
|
18
21
|
export interface PersistRecoveryDeps {
|
|
19
22
|
readonly sessionManager: {
|
|
@@ -48,166 +51,50 @@ export function createReopenRecoveryPanels(deps: ReopenPanelsDeps): (snapshot: S
|
|
|
48
51
|
};
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
// main.ts's 60s autosave (writeRecoveryFile) overwrites the single shared
|
|
55
|
-
// recovery.jsonl with the CURRENT session's live state within a minute — so
|
|
56
|
-
// the dismiss banner's promise ("still on disk; you will be asked again next
|
|
57
|
-
// time") went false silently well before the next launch. Fix: the moment
|
|
58
|
-
// the user dismisses (blocking-input.ts), copy recovery.jsonl aside to a
|
|
59
|
-
// preserved sibling file (recovery.jsonl.preserved) so it survives this
|
|
60
|
-
// session's later autosaves. Startup then checks BOTH the live file and the
|
|
61
|
-
// preserved sibling and offers whichever is newer (pickNewestRecoveryInfo),
|
|
62
|
-
// bounded to exactly one preserved file — a later dismiss replaces it, and
|
|
63
|
-
// that replacement is reported honestly (PreserveRecoveryResult.replacedPrevious)
|
|
64
|
-
// rather than silently dropping the older snapshot.
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
|
|
67
|
-
export interface RecoveryFileDeps {
|
|
54
|
+
export interface AutoRestoreRecoveryDeps {
|
|
55
|
+
readonly workingDirectory: string;
|
|
68
56
|
readonly homeDirectory: string;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
export interface PreservedRecoveryInfo {
|
|
73
|
-
readonly title: string;
|
|
74
|
-
readonly timestamp: number;
|
|
75
|
-
readonly sessionId: string;
|
|
76
|
-
readonly returnContext?: SessionReturnContextSummary | undefined;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export interface PreserveRecoveryResult {
|
|
80
|
-
/** True if a live recovery.jsonl existed and was copied to the preserved sibling. */
|
|
81
|
-
readonly preserved: boolean;
|
|
82
|
-
/** True if an existing preserved sibling (from an earlier dismiss) was overwritten. */
|
|
83
|
-
readonly replacedPrevious: boolean;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function preservedRecoveryPath(deps: RecoveryFileDeps): string {
|
|
87
|
-
return `${getRecoveryFilePath(deps.homeDirectory, deps.surfaceRoot)}.preserved`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function readRecoveryMetaLine(path: string): { title?: string; timestamp?: number; sessionId?: string; returnContext?: SessionReturnContextSummary } | null {
|
|
91
|
-
try {
|
|
92
|
-
if (!existsSync(path)) return null;
|
|
93
|
-
const firstLine = readFileSync(path, 'utf-8').split('\n')[0];
|
|
94
|
-
if (!firstLine) return null;
|
|
95
|
-
return JSON.parse(firstLine) as { title?: string; timestamp?: number; sessionId?: string; returnContext?: SessionReturnContextSummary };
|
|
96
|
-
} catch {
|
|
97
|
-
return null;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Copy the live recovery.jsonl aside to the preserved sibling. Called once, at dismiss. */
|
|
102
|
-
export function createPreserveRecoveryFile(deps: RecoveryFileDeps): () => PreserveRecoveryResult {
|
|
103
|
-
return () => {
|
|
104
|
-
const live = getRecoveryFilePath(deps.homeDirectory, deps.surfaceRoot);
|
|
105
|
-
const preserved = preservedRecoveryPath(deps);
|
|
106
|
-
if (!existsSync(live)) return { preserved: false, replacedPrevious: false };
|
|
107
|
-
const replacedPrevious = existsSync(preserved);
|
|
108
|
-
try {
|
|
109
|
-
copyFileSync(live, preserved);
|
|
110
|
-
return { preserved: true, replacedPrevious };
|
|
111
|
-
} catch {
|
|
112
|
-
return { preserved: false, replacedPrevious: false };
|
|
113
|
-
}
|
|
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;
|
|
114
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 };
|
|
115
67
|
}
|
|
116
68
|
|
|
117
69
|
/**
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
|
|
124
|
-
export function checkPreservedRecoveryFile(deps: RecoveryFileDeps): PreservedRecoveryInfo | null {
|
|
125
|
-
const meta = readRecoveryMetaLine(preservedRecoveryPath(deps));
|
|
126
|
-
if (!meta) return null;
|
|
127
|
-
return { title: meta.title ?? '', timestamp: meta.timestamp ?? 0, sessionId: meta.sessionId ?? '', returnContext: meta.returnContext };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Load the preserved sibling's full snapshot — same JSONL shape as recovery.jsonl since it's a byte-identical copy. */
|
|
131
|
-
export function loadPreservedRecoveryConversation(deps: RecoveryFileDeps): SessionSnapshot | null {
|
|
132
|
-
const path = preservedRecoveryPath(deps);
|
|
133
|
-
try {
|
|
134
|
-
if (!existsSync(path)) return null;
|
|
135
|
-
const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
|
|
136
|
-
if (lines.length < 2) return { messages: [] };
|
|
137
|
-
const metaLine = JSON.parse(lines[0]!) as { title?: string; titleSource?: SessionSnapshot['titleSource']; returnContext?: SessionSnapshot['returnContext'] };
|
|
138
|
-
return {
|
|
139
|
-
title: metaLine.title,
|
|
140
|
-
titleSource: metaLine.titleSource,
|
|
141
|
-
returnContext: metaLine.returnContext,
|
|
142
|
-
messages: lines.slice(1).map((line) => {
|
|
143
|
-
const { type: _type, ...rest } = JSON.parse(line) as { type: string } & Record<string, unknown>;
|
|
144
|
-
return rest;
|
|
145
|
-
}),
|
|
146
|
-
};
|
|
147
|
-
} catch {
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Remove the preserved sibling — called after it has been restored or discarded. */
|
|
153
|
-
export function deletePreservedRecoveryFile(deps: RecoveryFileDeps): void {
|
|
154
|
-
try {
|
|
155
|
-
unlinkSync(preservedRecoveryPath(deps));
|
|
156
|
-
} catch {
|
|
157
|
-
// missing file is fine
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Pick the newer of the live vs. preserved recovery info for the startup
|
|
163
|
-
* prompt, tagging which source it came from so the caller knows which
|
|
164
|
-
* load/delete pair to use. Null when neither exists.
|
|
165
|
-
*/
|
|
166
|
-
export function pickNewestRecoveryInfo<T extends { readonly timestamp: number }>(
|
|
167
|
-
live: T | null,
|
|
168
|
-
preserved: T | null,
|
|
169
|
-
): (T & { readonly source: 'live' | 'preserved' }) | null {
|
|
170
|
-
if (!live && !preserved) return null;
|
|
171
|
-
if (!live) return { ...(preserved as T), source: 'preserved' };
|
|
172
|
-
if (!preserved) return { ...live, source: 'live' };
|
|
173
|
-
return preserved.timestamp > live.timestamp
|
|
174
|
-
? { ...(preserved as T), source: 'preserved' }
|
|
175
|
-
: { ...live, source: 'live' };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Startup entry point: resolve which recovery prompt (if any) main.ts should
|
|
180
|
-
* show — the live recovery.jsonl, or an earlier dismiss's preserved sibling,
|
|
181
|
-
* whichever is newer. Bundles checkRecoveryFile + checkPreservedRecoveryFile
|
|
182
|
-
* + pickNewestRecoveryInfo so main.ts's entrypoint stays a one-liner.
|
|
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.
|
|
183
76
|
*/
|
|
184
|
-
export function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
export function createRecoveryFileOps(getSource: () => 'live' | 'preserved', deps: RecoveryFileDeps): RecoveryFileOps {
|
|
206
|
-
return {
|
|
207
|
-
loadRecoveryConversation: () => (getSource() === 'preserved' ? loadPreservedRecoveryConversation(deps) : loadLiveRecoveryConversation(deps)),
|
|
208
|
-
deleteRecoveryFile: () => (getSource() === 'preserved' ? deletePreservedRecoveryFile(deps) : deleteLiveRecoveryFile(deps)),
|
|
209
|
-
preserveRecoveryFile: getSource() === 'live' ? createPreserveRecoveryFile(deps) : undefined,
|
|
210
|
-
};
|
|
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;
|
|
211
98
|
}
|
|
212
99
|
|
|
213
100
|
export interface ErrorAffordanceDeps {
|
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.
|
|
9
|
+
let _version = '1.18.0';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
_version = pkg.version ?? _version;
|