@pellux/goodvibes-tui 1.19.5 → 1.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +143 -1613
- package/docs/README.md +4 -4
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +7 -7
- package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
- package/src/cli/entrypoint.ts +5 -1
- package/src/cli/launch-auto-update.ts +63 -4
- package/src/cli/tui-startup.ts +131 -15
- package/src/config/surface.ts +21 -0
- package/src/config/tui-extension-settings.ts +9 -1
- package/src/core/bookmark-navigation.ts +44 -0
- package/src/core/conversation-line-cache.ts +87 -19
- package/src/core/conversation-rendering.ts +186 -28
- package/src/core/conversation-tool-groups.ts +152 -0
- package/src/core/conversation-types.ts +30 -3
- package/src/core/conversation.ts +17 -0
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +62 -7
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/transcript-journal.ts +32 -6
- package/src/core/turn-event-wiring.ts +55 -12
- package/src/input/command-args-hint.ts +11 -2
- package/src/input/command-registry.ts +19 -0
- package/src/input/commands/checkpoint-runtime.ts +3 -3
- package/src/input/commands/health-runtime.ts +1 -3
- package/src/input/commands/local-runtime.ts +19 -2
- package/src/input/commands/rewind-runtime.ts +132 -1
- package/src/input/commands/runtime-services.ts +11 -1
- package/src/input/commands/secret-runtime.ts +1 -1
- package/src/input/commands/session-content.ts +9 -1
- package/src/input/commands/session-picker-filter.ts +28 -0
- package/src/input/commands/session-workflow.ts +62 -73
- package/src/input/commands/session.ts +36 -1
- package/src/input/commands/shell-core.ts +57 -5
- package/src/input/commands/update-runtime.ts +120 -4
- package/src/input/commands/websearch-runtime.ts +1 -1
- package/src/input/handler-content-actions.ts +63 -39
- package/src/input/handler-feed-routes.ts +17 -3
- package/src/input/handler-feed.ts +3 -0
- package/src/input/handler-interactions.ts +19 -23
- package/src/input/handler-modal-token-routes.ts +5 -0
- package/src/input/handler-types.ts +4 -1
- package/src/input/handler-ui-state.ts +6 -2
- package/src/input/handler.ts +19 -9
- package/src/input/search.ts +94 -2
- package/src/input/selection-modal-queue.ts +176 -0
- package/src/main.ts +55 -56
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/renderer/block-actions-overlay.ts +59 -0
- package/src/renderer/block-actions.ts +1 -3
- package/src/renderer/context-status-hint.ts +73 -5
- package/src/renderer/conversation-layout.ts +16 -0
- package/src/renderer/conversation-overlays.ts +6 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +15 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +1 -1
- package/src/runtime/bootstrap-command-context.ts +5 -1
- package/src/runtime/bootstrap-command-parts.ts +5 -2
- package/src/runtime/bootstrap-core.ts +3 -3
- package/src/runtime/bootstrap-hook-bridge.ts +62 -44
- package/src/runtime/bootstrap-shell.ts +31 -16
- package/src/runtime/bootstrap.ts +13 -18
- package/src/runtime/code-index-services.ts +19 -0
- package/src/runtime/durability-services.ts +11 -7
- package/src/runtime/index.ts +12 -1
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +312 -0
- package/src/runtime/resume-notice.ts +26 -21
- package/src/runtime/services.ts +14 -16
- package/src/runtime/session-liveness-marker.ts +120 -0
- package/src/runtime/session-pointer-surface.ts +22 -0
- package/src/runtime/session-resume-liveness-confirm.ts +84 -0
- package/src/runtime/session-storage-services.ts +41 -0
- package/src/runtime/workspace-checkpointing.ts +10 -2
- package/src/shell/recovery-input-helpers.ts +17 -98
- package/src/shell/retry-affordance.ts +123 -0
- package/src/shell/ui-openers.ts +9 -0
- package/src/utils/splash-lines.ts +1 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* recovery-prompt.ts — the startup "a recovery snapshot exists, do you want
|
|
3
|
+
* it?" flow.
|
|
4
|
+
*
|
|
5
|
+
* A crash-recovery snapshot used to get one sentence in the boot resume
|
|
6
|
+
* notice, which left the operator to read it and retype a command — and for a
|
|
7
|
+
* session that crashed before its first clean save, there was no command that
|
|
8
|
+
* reached the snapshot at all, so the sentence was a dead end. This module
|
|
9
|
+
* replaces that with an explicit two-step ask:
|
|
10
|
+
*
|
|
11
|
+
* 1. "Resume it?" [Resume] [Not now]
|
|
12
|
+
* 2. on decline: "Remove this recovery point?" [Keep] [Remove]
|
|
13
|
+
*
|
|
14
|
+
* Rules this flow keeps:
|
|
15
|
+
* - Nothing is ever applied without the user picking Resume. There is no
|
|
16
|
+
* silent auto-restore path here, and this module never loads a snapshot
|
|
17
|
+
* just to look at it — `checkRecoveryFile` reports, `consumeRecovery`
|
|
18
|
+
* loads, and only the second one runs after an explicit yes.
|
|
19
|
+
* - The SDK's `consumeRecovery` is load-then-delete: the snapshot file is
|
|
20
|
+
* retired only after a successful load, so a failed read can never
|
|
21
|
+
* destroy state that was never actually recovered.
|
|
22
|
+
* - Every fact shown is one we actually have (session id, snapshot age,
|
|
23
|
+
* title when the snapshot carries one, byte size when the file is where
|
|
24
|
+
* we can stat it). Nothing is estimated to fill the sentence out.
|
|
25
|
+
* - Those facts live in a row's `detail`, never in the modal title. The
|
|
26
|
+
* selection overlay truncates its title to the box width
|
|
27
|
+
* (selection-modal-overlay.ts) but wraps `detail` onto as many lines as it
|
|
28
|
+
* needs with no cap, so detail is the only place long copy is guaranteed
|
|
29
|
+
* to be shown in full. Titles here stay short enough to survive a narrow
|
|
30
|
+
* terminal intact.
|
|
31
|
+
* - A snapshot whose session still has a live pid marker is not offered at
|
|
32
|
+
* all: another terminal is refreshing it right now, so it is not an
|
|
33
|
+
* orphaned crash and resuming it here would fork that instance's live
|
|
34
|
+
* state.
|
|
35
|
+
* - Escape is not an answer. Dismissing either modal leaves the snapshot
|
|
36
|
+
* exactly where it is; only "Remove" deletes anything.
|
|
37
|
+
* - The destructive row is never the one Enter is already sitting on. Both
|
|
38
|
+
* modals open on their harmless first row, so answering either of them
|
|
39
|
+
* without reading keeps the snapshot.
|
|
40
|
+
* - Keep (or a dismissal) stays quiet for the rest of the run. The snapshot
|
|
41
|
+
* is offered again on the next launch, not again in this one.
|
|
42
|
+
*/
|
|
43
|
+
import { statSync } from 'node:fs';
|
|
44
|
+
import { checkRecoveryFile, checkRecoveryForSession, consumeRecovery, removeRecoveryPoint } from '@/runtime/index.ts';
|
|
45
|
+
import type { RecoveryFileInfo, SessionSurface } from '@/runtime/index.ts';
|
|
46
|
+
import { checkSessionLiveness } from './session-liveness-marker.ts';
|
|
47
|
+
import type { SelectionItem, SelectionResult } from '../input/selection-modal.ts';
|
|
48
|
+
|
|
49
|
+
/** How the offer ended. Returned so startup wiring (and tests) can assert the real outcome. */
|
|
50
|
+
export type RecoveryPromptOutcome =
|
|
51
|
+
| 'none' // no snapshot, or one that belongs to a still-live session
|
|
52
|
+
| 'resumed' // user chose Resume and the snapshot was applied
|
|
53
|
+
| 'resume-failed' // user chose Resume but the snapshot could not be loaded
|
|
54
|
+
| 'removed' // user declined and chose Remove
|
|
55
|
+
| 'kept'; // user declined and chose Keep, or dismissed a modal
|
|
56
|
+
|
|
57
|
+
/** Open a selection modal and resolve with the chosen item id (null on dismissal). */
|
|
58
|
+
export type SelectionOpener = (
|
|
59
|
+
title: string,
|
|
60
|
+
items: SelectionItem[],
|
|
61
|
+
opts: { preSelectId?: string; allowSearch?: boolean; primaryVerbLabel?: string } | undefined,
|
|
62
|
+
callback: (result: SelectionResult | null) => void,
|
|
63
|
+
) => void;
|
|
64
|
+
|
|
65
|
+
export interface RecoveryPromptDeps {
|
|
66
|
+
readonly surface: SessionSurface;
|
|
67
|
+
/** The shell's selection-modal opener. Absent in headless hosts — the flow then does nothing at all. */
|
|
68
|
+
readonly openSelection: SelectionOpener | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Apply an explicitly-accepted snapshot to the live conversation and report
|
|
71
|
+
* how many messages ended up in it. Supplied by the startup wiring so this
|
|
72
|
+
* module never reaches into conversation state itself.
|
|
73
|
+
*/
|
|
74
|
+
readonly applySnapshot: (payload: { readonly snapshot: Record<string, unknown>; readonly sessionId: string }) => number;
|
|
75
|
+
/** One-line honest receipt into the transcript. */
|
|
76
|
+
readonly receipt: (line: string) => void;
|
|
77
|
+
readonly render: () => void;
|
|
78
|
+
/** Injectable for tests. Defaults to the real wall clock. */
|
|
79
|
+
readonly now?: () => number;
|
|
80
|
+
/** Injectable for tests. Defaults to statSync on the surface's own recovery file. */
|
|
81
|
+
readonly snapshotBytes?: (sessionId: string) => number | null;
|
|
82
|
+
/**
|
|
83
|
+
* When set, offer THIS session's snapshot specifically (via
|
|
84
|
+
* `checkRecoveryForSession`) instead of the newest snapshot across every
|
|
85
|
+
* session (`checkRecoveryFile`). Used by the `--continue` / bare `--resume`
|
|
86
|
+
* pre-resume check (cli/tui-startup.ts): resuming a named session straight
|
|
87
|
+
* from its durable store would silently drop the tail of messages held only
|
|
88
|
+
* in a snapshot newer than that store — the same rule `checkRecoveryFile`
|
|
89
|
+
* uses, just scoped to the one session being resumed instead of "whichever
|
|
90
|
+
* session has the newest snapshot".
|
|
91
|
+
*/
|
|
92
|
+
readonly targetSessionId?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ─── Honest fact formatting ─────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** Human-readable age of a snapshot. Coarse on purpose — the exact second is not a fact worth claiming. */
|
|
98
|
+
export function formatSnapshotAge(ageMs: number): string {
|
|
99
|
+
if (ageMs < 0) return 'just now';
|
|
100
|
+
const minutes = Math.floor(ageMs / 60_000);
|
|
101
|
+
if (minutes < 1) return 'just now';
|
|
102
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
103
|
+
const hours = Math.floor(minutes / 60);
|
|
104
|
+
if (hours < 24) return `${hours}h ago`;
|
|
105
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Compact byte size. Only ever called with a size we actually read off disk. */
|
|
109
|
+
export function formatSnapshotSize(bytes: number): string {
|
|
110
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
111
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
112
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Short, fixed modal titles. Kept well inside the narrowest box width so neither is ever truncated. */
|
|
116
|
+
export const RECOVERY_OFFER_TITLE = 'Recovery snapshot found';
|
|
117
|
+
export const RECOVERY_RETIRE_TITLE = 'Remove recovery point?';
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The facts line shown in the modal. Only includes what is genuinely known:
|
|
121
|
+
* the session id and age are always available from `checkRecoveryFile`; the
|
|
122
|
+
* title appears only when the snapshot carries a non-empty one; the size
|
|
123
|
+
* appears only when the file could be stat'd.
|
|
124
|
+
*/
|
|
125
|
+
export function describeRecoverySnapshot(
|
|
126
|
+
info: RecoveryFileInfo,
|
|
127
|
+
facts: { readonly nowMs: number; readonly bytes: number | null },
|
|
128
|
+
): string {
|
|
129
|
+
const parts = [`session ${info.sessionId}`, formatSnapshotAge(facts.nowMs - info.timestamp)];
|
|
130
|
+
if (info.title && info.title.trim()) parts.push(`"${info.title.trim()}"`);
|
|
131
|
+
if (facts.bytes !== null) parts.push(formatSnapshotSize(facts.bytes));
|
|
132
|
+
return parts.join(' · ');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The offer modal's rows. The snapshot's facts ride on the Resume row's
|
|
137
|
+
* `detail` — both because that is where they are decision-relevant ("this is
|
|
138
|
+
* what you would be loading") and because `detail` is the only field the
|
|
139
|
+
* selection overlay wraps rather than truncates, so the full description
|
|
140
|
+
* survives any terminal width.
|
|
141
|
+
*/
|
|
142
|
+
export function buildRecoveryOfferItems(facts: string): SelectionItem[] {
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
id: 'resume',
|
|
146
|
+
label: 'Resume it',
|
|
147
|
+
detail: `Loads ${facts} into this session. The recovery point is deleted once it loads.`,
|
|
148
|
+
primaryAction: 'select',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'not-now',
|
|
152
|
+
label: 'Not now',
|
|
153
|
+
detail: 'Start fresh. You will be asked whether to keep or remove the recovery point.',
|
|
154
|
+
primaryAction: 'select',
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The follow-up modal's rows, shown only after an explicit decline. Restates
|
|
161
|
+
* which snapshot is at stake.
|
|
162
|
+
*
|
|
163
|
+
* Keep comes first, and so is what a blind Enter lands on. This modal is the
|
|
164
|
+
* one place in the flow where a keypress can destroy a conversation, and it
|
|
165
|
+
* appears unrequested at boot, so the row under the cursor has to be the one
|
|
166
|
+
* that costs nothing: pressing Enter without reading leaves the snapshot on
|
|
167
|
+
* disk to be offered again. Deleting it takes a deliberate move down first.
|
|
168
|
+
*/
|
|
169
|
+
export function buildRecoveryRetireItems(facts: string): SelectionItem[] {
|
|
170
|
+
return [
|
|
171
|
+
{
|
|
172
|
+
id: 'keep',
|
|
173
|
+
label: 'Keep it',
|
|
174
|
+
detail: 'Leave the recovery point on disk. It will be offered again the next time this workspace opens.',
|
|
175
|
+
primaryAction: 'select',
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
id: 'remove',
|
|
179
|
+
label: 'Remove it',
|
|
180
|
+
detail: `Deletes the recovery point for ${facts}. The conversation it holds cannot be recovered afterwards.`,
|
|
181
|
+
primaryAction: 'select',
|
|
182
|
+
},
|
|
183
|
+
];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Flow ───────────────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function ask(open: SelectionOpener, title: string, items: SelectionItem[]): Promise<string | null> {
|
|
189
|
+
return new Promise((resolve) => {
|
|
190
|
+
open(title, items, { allowSearch: false, primaryVerbLabel: 'Choose' }, (result) => {
|
|
191
|
+
resolve(result?.item.id ?? null);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function defaultSnapshotBytes(surface: SessionSurface, sessionId: string): number | null {
|
|
197
|
+
try {
|
|
198
|
+
return statSync(surface.recoveryFile(sessionId)).size;
|
|
199
|
+
} catch {
|
|
200
|
+
// The snapshot may have come from the SDK's legacy dual-read location,
|
|
201
|
+
// which this path cannot address. Report no size rather than a wrong one.
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Snapshots the user has already answered about (Keep or dismissal) during
|
|
208
|
+
* this process's lifetime. Two offer paths exist in one boot — the targeted
|
|
209
|
+
* pre-resume check for `--continue`/`--resume` and the general startup offer —
|
|
210
|
+
* and both can find the same snapshot; an answer given to either binds both,
|
|
211
|
+
* so "stays quiet for the rest of the run" holds across the pair. Resume and
|
|
212
|
+
* Remove retire the file itself, so only the declined cases need remembering.
|
|
213
|
+
*/
|
|
214
|
+
const answeredThisRun = new Set<string>();
|
|
215
|
+
|
|
216
|
+
/** Test seam: a fresh process has no answered offers. */
|
|
217
|
+
export function resetAnsweredRecoveryOffersForTest(): void {
|
|
218
|
+
answeredThisRun.clear();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Run the startup recovery offer. Resolves once the user has answered (or
|
|
223
|
+
* once it is established that there is nothing to ask about). Never throws:
|
|
224
|
+
* a failure anywhere in here must not take a boot down.
|
|
225
|
+
*/
|
|
226
|
+
export async function offerRecoverySnapshot(deps: RecoveryPromptDeps): Promise<RecoveryPromptOutcome> {
|
|
227
|
+
try {
|
|
228
|
+
const info = deps.targetSessionId !== undefined
|
|
229
|
+
? checkRecoveryForSession(deps.surface, deps.targetSessionId)
|
|
230
|
+
: checkRecoveryFile({ surface: deps.surface });
|
|
231
|
+
if (!info) return 'none';
|
|
232
|
+
// Already answered about this snapshot earlier in this same run (the
|
|
233
|
+
// targeted --continue offer and the general startup offer can both find
|
|
234
|
+
// it). The earlier answer stands; don't ask twice.
|
|
235
|
+
if (answeredThisRun.has(info.sessionId)) return 'kept';
|
|
236
|
+
// Another terminal is actively refreshing this snapshot — it is that
|
|
237
|
+
// instance's live state, not an orphaned crash.
|
|
238
|
+
if (checkSessionLiveness(deps.surface, info.sessionId).live) return 'none';
|
|
239
|
+
const open = deps.openSelection;
|
|
240
|
+
if (!open) return 'none';
|
|
241
|
+
|
|
242
|
+
const nowMs = deps.now?.() ?? Date.now();
|
|
243
|
+
const bytes = deps.snapshotBytes ? deps.snapshotBytes(info.sessionId) : defaultSnapshotBytes(deps.surface, info.sessionId);
|
|
244
|
+
const facts = describeRecoverySnapshot(info, { nowMs, bytes });
|
|
245
|
+
|
|
246
|
+
const answer = await ask(open, RECOVERY_OFFER_TITLE, buildRecoveryOfferItems(facts));
|
|
247
|
+
|
|
248
|
+
if (answer === 'resume') {
|
|
249
|
+
const { snapshot, consumed } = consumeRecovery(deps.surface, info.sessionId);
|
|
250
|
+
if (!snapshot || !consumed) {
|
|
251
|
+
// consumeRecovery leaves the file alone when the load fails, so the
|
|
252
|
+
// snapshot is still there to try again next launch. Say so.
|
|
253
|
+
deps.receipt('Recovery snapshot could not be read — it was left on disk and will be offered again next launch.');
|
|
254
|
+
deps.render();
|
|
255
|
+
return 'resume-failed';
|
|
256
|
+
}
|
|
257
|
+
const messageCount = deps.applySnapshot({ snapshot: snapshot as unknown as Record<string, unknown>, sessionId: info.sessionId });
|
|
258
|
+
deps.receipt(`Recovery snapshot restored: ${messageCount} message(s) from session ${info.sessionId}. The recovery point has been retired.`);
|
|
259
|
+
deps.render();
|
|
260
|
+
return 'resumed';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (answer !== 'not-now') {
|
|
264
|
+
// Dismissed rather than answered. Nothing is deleted and nothing is
|
|
265
|
+
// asked again this run.
|
|
266
|
+
answeredThisRun.add(info.sessionId);
|
|
267
|
+
return 'kept';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const retire = await ask(open, RECOVERY_RETIRE_TITLE, buildRecoveryRetireItems(facts));
|
|
271
|
+
if (retire === 'remove') {
|
|
272
|
+
const { removed } = removeRecoveryPoint(deps.surface, info.sessionId);
|
|
273
|
+
deps.receipt(removed
|
|
274
|
+
? `Recovery point removed (session ${info.sessionId}).`
|
|
275
|
+
: `No recovery point was found to remove (session ${info.sessionId}).`);
|
|
276
|
+
deps.render();
|
|
277
|
+
return 'removed';
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
answeredThisRun.add(info.sessionId);
|
|
281
|
+
deps.receipt(`Recovery point kept (session ${info.sessionId}) — it will be offered again next launch.`);
|
|
282
|
+
deps.render();
|
|
283
|
+
return 'kept';
|
|
284
|
+
} catch {
|
|
285
|
+
// Best-effort by construction: a recovery offer that fails must leave the
|
|
286
|
+
// snapshot untouched and the boot unharmed.
|
|
287
|
+
return 'none';
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── Startup wiring ─────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Raise the recovery offer once the shell is up.
|
|
295
|
+
*
|
|
296
|
+
* Deliberately fire-and-forget and deliberately AFTER the first render: the
|
|
297
|
+
* modal is drawn by the render loop, so asking before a frame exists would
|
|
298
|
+
* mean a question nobody can see, and awaiting it would hold the terminal
|
|
299
|
+
* blank while the user was expected to answer. Scheduled on a macrotask so
|
|
300
|
+
* the caller's own initial render has completed first.
|
|
301
|
+
*
|
|
302
|
+
* Every failure mode here ends with the snapshot untouched: `offerRecoverySnapshot`
|
|
303
|
+
* swallows its own errors, and this wrapper adds a catch for the scheduling
|
|
304
|
+
* boundary.
|
|
305
|
+
*/
|
|
306
|
+
export function scheduleRecoveryOffer(deps: RecoveryPromptDeps): void {
|
|
307
|
+
setTimeout(() => {
|
|
308
|
+
void offerRecoverySnapshot(deps).catch(() => {
|
|
309
|
+
// Best-effort: a failed offer must never take the shell down.
|
|
310
|
+
});
|
|
311
|
+
}, 0).unref?.();
|
|
312
|
+
}
|
|
@@ -10,13 +10,16 @@
|
|
|
10
10
|
* what real state exists and how to reach it.
|
|
11
11
|
*
|
|
12
12
|
* Honesty constraints (verified against the actual runtime, not assumed):
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `/session resume <id
|
|
19
|
-
*
|
|
13
|
+
* - `/resume` and `/sessions` both exist and both work: `/resume`
|
|
14
|
+
* (session.ts) opens a zero-typing picker over saved sessions when
|
|
15
|
+
* called bare, and resumes directly when given an id/name; `/sessions`
|
|
16
|
+
* (session-content.ts) forwards a `resume <id>` subcommand to the same
|
|
17
|
+
* `/session resume` path. All three spellings — `/resume <id>`,
|
|
18
|
+
* `/session resume <id>`, `/sessions resume <id>` — reach the identical
|
|
19
|
+
* resume routine. This notice leads with the zero-typing picker
|
|
20
|
+
* (`/resume`) and keeps the direct, exact-id form (`/session resume
|
|
21
|
+
* <id>`) as a secondary hint, for the case of already knowing which
|
|
22
|
+
* session to reach for.
|
|
20
23
|
* - `/checkpoints` works with zero arguments and behaves correctly at any
|
|
21
24
|
* checkpoint count (including zero) — advertised whenever the checkpoint
|
|
22
25
|
* manager is available in this session.
|
|
@@ -27,9 +30,15 @@
|
|
|
27
30
|
* - Every clause is independently gated on real data: a claim about
|
|
28
31
|
* checkpoints/chain history is only made when that data is known; "no
|
|
29
32
|
* chain history" means no chain clause is printed, not a fabricated one.
|
|
33
|
+
* - A live crash-recovery snapshot is NOT this notice's business any more.
|
|
34
|
+
* It used to get a clause here, which meant an operator's only route back
|
|
35
|
+
* to a crashed session was reading a sentence and retyping a command. It
|
|
36
|
+
* is now an explicit ask-then-retire modal raised right after first
|
|
37
|
+
* render — see runtime/recovery-prompt.ts. Keeping a passive clause here
|
|
38
|
+
* as well would announce the same snapshot twice.
|
|
30
39
|
*/
|
|
31
40
|
|
|
32
|
-
import { readLastSessionPointer } from '@/runtime/index.ts';
|
|
41
|
+
import { readLastSessionPointer, type SessionSurface } from '@/runtime/index.ts';
|
|
33
42
|
import type { WrfcChain } from '@pellux/goodvibes-sdk/platform/agents';
|
|
34
43
|
import type { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
|
|
35
44
|
import type { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
|
|
@@ -122,8 +131,9 @@ export function buildResumeNotice(facts: ResumeNoticeFacts): string | null {
|
|
|
122
131
|
let notice = `${lead}: ${summary.join(', ')}`;
|
|
123
132
|
|
|
124
133
|
const hints: string[] = [];
|
|
125
|
-
//
|
|
126
|
-
|
|
134
|
+
// Lead with the zero-typing picker (/resume), keep the direct exact-id
|
|
135
|
+
// form as a secondary hint — see the header doc for why both are honest.
|
|
136
|
+
if (hasSession) hints.push(`/resume to continue (or /session resume ${facts.lastSessionId} directly)`);
|
|
127
137
|
if (hasCheckpoints) hints.push('/checkpoints to browse');
|
|
128
138
|
if (facts.memoryAvailable) hints.push('/recall for memory');
|
|
129
139
|
if (hints.length > 0) notice += ` — ${hints.join(' · ')}`;
|
|
@@ -134,10 +144,8 @@ export function buildResumeNotice(facts: ResumeNoticeFacts): string | null {
|
|
|
134
144
|
// ─── Fact gathering (I/O) ────────────────────────────────────────────────────
|
|
135
145
|
|
|
136
146
|
export interface ResumeNoticeDeps {
|
|
137
|
-
|
|
138
|
-
readonly
|
|
139
|
-
/** Surface root used by session persistence — the TUI always uses 'tui'. */
|
|
140
|
-
readonly surfaceRoot: string;
|
|
147
|
+
/** The app's declare-once session-storage handle — the same one the runtime writes the pointer through. */
|
|
148
|
+
readonly surface: SessionSurface;
|
|
141
149
|
/** Only `load()` is needed — kept narrow for testability. */
|
|
142
150
|
readonly sessionManager: Pick<SessionManager, 'load'>;
|
|
143
151
|
/** Undefined when checkpoints are not wired up in this session at all. Only `list()` is needed. */
|
|
@@ -157,12 +165,8 @@ export interface ResumeNoticeDeps {
|
|
|
157
165
|
* session file is missing/corrupt — never a claim about a session that
|
|
158
166
|
* cannot actually be resumed.
|
|
159
167
|
*/
|
|
160
|
-
function readLastSessionTurns(deps: Pick<ResumeNoticeDeps, '
|
|
161
|
-
const lastSessionId = readLastSessionPointer({
|
|
162
|
-
workingDirectory: deps.workingDirectory,
|
|
163
|
-
homeDirectory: deps.homeDirectory,
|
|
164
|
-
surfaceRoot: deps.surfaceRoot,
|
|
165
|
-
});
|
|
168
|
+
function readLastSessionTurns(deps: Pick<ResumeNoticeDeps, 'surface' | 'sessionManager'>): { turnCount: number; lastSessionId: string } | null {
|
|
169
|
+
const lastSessionId = readLastSessionPointer({ surface: deps.surface });
|
|
166
170
|
if (!lastSessionId) return null;
|
|
167
171
|
try {
|
|
168
172
|
const { messages } = deps.sessionManager.load(lastSessionId);
|
|
@@ -190,7 +194,8 @@ async function readCheckpointCount(mgr: ResumeNoticeDeps['checkpointManager']):
|
|
|
190
194
|
* Gather real facts from disk/services and, if there is anything to report,
|
|
191
195
|
* print ONE compact system message via `deps.router.high`. No-op (and no
|
|
192
196
|
* message) when there is no prior session, no checkpoints, and no chain
|
|
193
|
-
* history — a fresh working directory stays quiet.
|
|
197
|
+
* history — a fresh working directory stays quiet. Nothing here ever mutates
|
|
198
|
+
* the conversation: this is a report, not a restore.
|
|
194
199
|
*/
|
|
195
200
|
export async function announceResumeState(deps: ResumeNoticeDeps): Promise<void> {
|
|
196
201
|
const session = readLastSessionTurns(deps);
|
package/src/runtime/services.ts
CHANGED
|
@@ -37,7 +37,9 @@ import {
|
|
|
37
37
|
type RuntimeEventBus, DistributedRuntimeManager, RemoteRunnerRegistry, RemoteSupervisor, IntegrationHelperService,
|
|
38
38
|
IdempotencyStore, ComponentHealthMonitor, WorktreeRegistry, SandboxSessionRegistry, createShellPathService,
|
|
39
39
|
type ShellPathService, type FeatureFlagManager, createFeatureFlagManager, PolicyRuntimeState,
|
|
40
|
+
type SessionSurface,
|
|
40
41
|
} from '@/runtime/index.ts';
|
|
42
|
+
import { createSessionStorageServices } from './session-storage-services.ts';
|
|
41
43
|
import { VoiceProviderRegistry, VoiceService, ensureBuiltinVoiceProviders } from '@pellux/goodvibes-sdk/platform/voice';
|
|
42
44
|
import { CacheRegistry, PauseController, type MemoryGovernor } from '@pellux/goodvibes-sdk/platform/runtime/memory';
|
|
43
45
|
import { wireMemoryGovernance } from './memory-governance-services.ts';
|
|
@@ -63,7 +65,7 @@ import { type ArchivableProcessRegistry } from '@pellux/goodvibes-sdk/platform/r
|
|
|
63
65
|
import { createFleetServices } from './fleet-services.ts';
|
|
64
66
|
import { createWorkstreamServices, type OrchestrationEngine, type WorkstreamCommandService } from './workstream-services.ts';
|
|
65
67
|
import { wireFleetNeedsInputPush } from './fleet-needs-input-push.ts';
|
|
66
|
-
import { codeIndexDbPath, createCodeIndexServices, isCodeInjectionSettingEnabled } from './code-index-services.ts';
|
|
68
|
+
import { codeIndexDbPath, createCodeIndexServices, createStoreRerooter, isCodeInjectionSettingEnabled } from './code-index-services.ts';
|
|
67
69
|
import type { WorkPlanStore } from '../work-plans/work-plan-store.ts';
|
|
68
70
|
import { registerDaemonHandlers, type DaemonHandlerSurfaces } from '../daemon/handlers/index.ts';
|
|
69
71
|
import type { HandlerContext, HandlerLogger } from '../daemon/handlers/context.ts';
|
|
@@ -99,6 +101,8 @@ export interface RuntimeServicesOptions {
|
|
|
99
101
|
export interface RuntimeServices {
|
|
100
102
|
readonly workingDirectory: string;
|
|
101
103
|
readonly homeDirectory: string;
|
|
104
|
+
/** The declare-once session-storage handle every session reader and writer threads through — see session-storage-services.ts. */
|
|
105
|
+
readonly surface: SessionSurface;
|
|
102
106
|
readonly shellPaths: ShellPathService;
|
|
103
107
|
readonly configManager: ConfigManager;
|
|
104
108
|
readonly featureFlags: FeatureFlagManager;
|
|
@@ -231,6 +235,8 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
231
235
|
workingDirectory,
|
|
232
236
|
homeDirectory,
|
|
233
237
|
});
|
|
238
|
+
// Built before anything that touches session state — see session-storage-services.ts.
|
|
239
|
+
const { surface, sessionManager } = createSessionStorageServices({ workingDirectory, homeDirectory });
|
|
234
240
|
const workspaceTrustManager = new WorkspaceTrustManager({ shellPaths });
|
|
235
241
|
const configManager = options.configManager;
|
|
236
242
|
const featureFlags = options.featureFlags ?? createFeatureFlagManager();
|
|
@@ -318,9 +324,8 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
318
324
|
});
|
|
319
325
|
const profileManager = new ProfileManager(shellPaths.resolveUserPath('tui', 'profiles'));
|
|
320
326
|
const bookmarkManager = new BookmarkManager(shellPaths.resolveUserPath('tui', 'bookmarks'));
|
|
321
|
-
const sessionManager = new SessionManager(workingDirectory, { surfaceRoot: 'tui' });
|
|
322
327
|
const sessionOrchestration = new CrossSessionTaskRegistry(
|
|
323
|
-
|
|
328
|
+
join(surface.sessionsDir, 'task-graph.json'),
|
|
324
329
|
);
|
|
325
330
|
const hookActivityTracker = new HookActivityTracker();
|
|
326
331
|
const watcherRegistry = new WatcherRegistry({
|
|
@@ -564,7 +569,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
564
569
|
// Store snapshots + durable remembered-approval rules + the live credential
|
|
565
570
|
// chain, mirroring the SDK composition — see durability-services.ts.
|
|
566
571
|
const { storeSnapshotScheduler, userPermissionRuleStore } = createDurabilityServices({
|
|
567
|
-
configManager, secretsManager, providerRegistry, memoryDbPath, codeIndexDbPath: codeIndexDbPath(workingDirectory),
|
|
572
|
+
configManager, secretsManager, providerRegistry, memoryDbPath, codeIndexDbPath: codeIndexDbPath(workingDirectory), surface, shellPaths, // + retention-sweep roots & live config watch (mirrors the SDK)
|
|
568
573
|
});
|
|
569
574
|
const codeInjectionOrchestratorDeps = { codeIndex: codeIndexStore, isCodeInjectionSettingEnabled: () => isCodeInjectionSettingEnabled(configManager), codeIndexReindexScheduler }; // Code-injection seam (agent here; main via orchestrator-core-services.ts)
|
|
570
575
|
const { processRegistry } = createFleetServices({ // Shared archive-aware fleet registry (+ daemon observed rows) — see fleet-services.ts
|
|
@@ -578,7 +583,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
578
583
|
observeExternalAgents: options.observeExternalAgents, providerRegistry, // observeExternalAgents is daemon-side only
|
|
579
584
|
});
|
|
580
585
|
const modeManager = new ModeManager(); const fileUndoManager = new FileUndoManager();
|
|
581
|
-
const workspaceCheckpointManager = createWorkspaceCheckpointing({ workspaceRoot: workingDirectory, runtimeBus: options.runtimeBus, configManager });
|
|
586
|
+
const workspaceCheckpointManager = createWorkspaceCheckpointing({ workspaceRoot: workingDirectory, surface, runtimeBus: options.runtimeBus, configManager });
|
|
582
587
|
// memory-consolidation honors governor backpressure: it ticks only when idle
|
|
583
588
|
// AND the 'memory-consolidation' job is not paused AND expensive work is
|
|
584
589
|
// admitted (mirrors the SDK's own createRuntimeServices idle gate).
|
|
@@ -610,8 +615,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
610
615
|
// Terminal-shell wrapper over the SDK registerGatewayVerbGroups (gateway-verbs.ts); checkin.*/fleet-needs-input/pairing.* register only when their deps are present. memoryGovernor lights up ops.memory.get; voiceSetup lights up voice.local.status/install.
|
|
611
616
|
attachWsOnlyGatewayVerbHandlers(gatewayMethods, { processRegistry, workspaceCheckpointManager, conversationRewindPort: createSessionConversationRewindPort(), sessionBroker, secretsManager, stepUpService, approvalBroker, requestApproval: (input) => approvalBroker.requestApproval(input), watcherRegistry, userPermissionRuleStore, shellPaths, configManager, runtimeStore: options.runtimeStore, channelDeliveryRouter, providerRegistry, automationManager, sessionLister: sessionBroker, sessionIntake: sessionBroker, workingDirectory, memoryRegistry, pairingTokens, sessionLiveTurnControls, powerManager, memoryGovernor, voiceSetup, relayAvailable: () => configManager.get('relay.enabled') === true, pairingWebOrigin: () => resolvePairingWebOrigin(configManager).origin, ...wireFleetNeedsInputPush({ registry: processRegistry, runtimeBus: options.runtimeBus, sessionBroker }) });
|
|
612
617
|
const integrationHelpers = new IntegrationHelperService({
|
|
613
|
-
|
|
614
|
-
homeDirectory,
|
|
618
|
+
surface, // surface-scoped: continuity's recovery-file check must read the SAME paths the app writes with, not the unscoped legacy pair.
|
|
615
619
|
runtimeStore: options.runtimeStore,
|
|
616
620
|
runtimeBus: options.runtimeBus,
|
|
617
621
|
configManager,
|
|
@@ -636,7 +640,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
636
640
|
// Exec stuck on a terminal prompt rides the approval broker; the typed answer feeds the continuing run. Built once and shared (like localhostFetchApproval) so the tool registry AND every setDependencies site — incl. the post-clobber rewire in bootstrap-core — install the SAME handler; otherwise a wholesale replace drops it and interactive prompts hang.
|
|
637
641
|
const execPromptAnswerHandler = buildExecPromptAnswerHandler({ requestApproval: (input) => approvalBroker.requestApproval(input) });
|
|
638
642
|
agentOrchestrator.setDependencies({
|
|
639
|
-
surfaceRoot:
|
|
643
|
+
surfaceRoot: surface.surfaceRoot,
|
|
640
644
|
execPromptAnswerHandler,
|
|
641
645
|
localhostFetchApproval,
|
|
642
646
|
fileCache,
|
|
@@ -678,6 +682,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
678
682
|
return {
|
|
679
683
|
workingDirectory,
|
|
680
684
|
homeDirectory,
|
|
685
|
+
surface,
|
|
681
686
|
shellPaths,
|
|
682
687
|
workspaceTrustManager,
|
|
683
688
|
configManager,
|
|
@@ -785,13 +790,6 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
|
|
|
785
790
|
fileUndoManager,
|
|
786
791
|
workspaceCheckpointManager,
|
|
787
792
|
integrationHelpers,
|
|
788
|
-
|
|
789
|
-
// The memory store is the home-scoped canonical cross-surface store and
|
|
790
|
-
// deliberately does NOT reroot per-project (that would re-silo memory back
|
|
791
|
-
// into per-project stores). Only working-tree-bound stores (code index,
|
|
792
|
-
// project index) reroot.
|
|
793
|
-
await codeIndexStore.reroot(newWorkingDir, codeIndexDbPath(newWorkingDir));
|
|
794
|
-
await projectIndex.reroot(newWorkingDir);
|
|
795
|
-
},
|
|
793
|
+
rerootStores: createStoreRerooter({ codeIndexStore, projectIndex }),
|
|
796
794
|
};
|
|
797
795
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-liveness-marker.ts — best-effort "this session is open in another
|
|
3
|
+
* terminal" signal for multi-instance safety.
|
|
4
|
+
*
|
|
5
|
+
* Two independent TUI instances resuming or writing recovery snapshots for
|
|
6
|
+
* the SAME session can silently step on each other: one instance's periodic
|
|
7
|
+
* recovery write looks, to a second instance, indistinguishable from a
|
|
8
|
+
* genuine crash snapshot, and resuming a session another instance still has
|
|
9
|
+
* open forks its live state without warning.
|
|
10
|
+
*
|
|
11
|
+
* This module writes a small marker file (session id + pid), refreshed on
|
|
12
|
+
* the SAME 60s cadence as the existing recovery-file write (main.ts), so a
|
|
13
|
+
* second instance can do a best-effort liveness check before either warning
|
|
14
|
+
* about a stale-looking recovery snapshot or resuming a live session out
|
|
15
|
+
* from under its owner.
|
|
16
|
+
*
|
|
17
|
+
* Every check here is best-effort by design: a missing or stale marker never
|
|
18
|
+
* blocks anything — it just means "we can't tell, proceed as before". This is
|
|
19
|
+
* a convenience signal, not a lock.
|
|
20
|
+
*
|
|
21
|
+
* Location: <homeDirectory>/.goodvibes/<surfaceRoot>/liveness/<sessionId>.json.
|
|
22
|
+
* Both halves come off the caller's SessionSurface rather than being spelled
|
|
23
|
+
* out here, so the markers land under the same scope as the sessions and
|
|
24
|
+
* recovery snapshots they describe — mirrors the transcript-journal.ts
|
|
25
|
+
* convention (homeDirectory-scoped, under the surface's own directory).
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { dirname, join } from 'node:path';
|
|
29
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
30
|
+
|
|
31
|
+
export interface LivenessMarker {
|
|
32
|
+
readonly sessionId: string;
|
|
33
|
+
readonly pid: number;
|
|
34
|
+
readonly updatedAt: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A marker older than this is treated as stale even if its pid happens to
|
|
39
|
+
* still resolve to a running process (pid reuse after a crash) — the marker
|
|
40
|
+
* is refreshed every 60s (main.ts's recovery cadence), so anything past
|
|
41
|
+
* ~2.5x that cadence means the writer stopped refreshing it.
|
|
42
|
+
*/
|
|
43
|
+
export const LIVENESS_STALE_AFTER_MS = 150_000;
|
|
44
|
+
|
|
45
|
+
/** Absolute path to a session's liveness marker file. */
|
|
46
|
+
export function livenessMarkerPathFor(surface: SessionSurface, sessionId: string): string {
|
|
47
|
+
return join(surface.homeDirectory, '.goodvibes', surface.surfaceRoot, 'liveness', `${sessionId}.json`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Refresh (creating if needed) the liveness marker for this session/pid. Best-effort — never throws. */
|
|
51
|
+
export function writeLivenessMarker(surface: SessionSurface, sessionId: string, pid: number = process.pid): void {
|
|
52
|
+
try {
|
|
53
|
+
const path = livenessMarkerPathFor(surface, sessionId);
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
55
|
+
const marker: LivenessMarker = { sessionId, pid, updatedAt: Date.now() };
|
|
56
|
+
writeFileSync(path, JSON.stringify(marker), { mode: 0o600 });
|
|
57
|
+
} catch {
|
|
58
|
+
// Best-effort — a missed liveness refresh never blocks anything.
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Best-effort removal on exit. Never throws. */
|
|
63
|
+
export function removeLivenessMarker(surface: SessionSurface, sessionId: string): void {
|
|
64
|
+
try {
|
|
65
|
+
const path = livenessMarkerPathFor(surface, sessionId);
|
|
66
|
+
if (existsSync(path)) unlinkSync(path);
|
|
67
|
+
} catch {
|
|
68
|
+
// Best-effort.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readLivenessMarker(surface: SessionSurface, sessionId: string): LivenessMarker | null {
|
|
73
|
+
try {
|
|
74
|
+
const path = livenessMarkerPathFor(surface, sessionId);
|
|
75
|
+
if (!existsSync(path)) return null;
|
|
76
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as Partial<LivenessMarker>;
|
|
77
|
+
if (typeof parsed.sessionId !== 'string' || typeof parsed.pid !== 'number' || typeof parsed.updatedAt !== 'number') return null;
|
|
78
|
+
return { sessionId: parsed.sessionId, pid: parsed.pid, updatedAt: parsed.updatedAt };
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Best-effort check that a pid still refers to a running process. Injectable for tests. */
|
|
85
|
+
export function isPidAlive(pid: number, kill: (pid: number, signal: 0) => void = process.kill.bind(process)): boolean {
|
|
86
|
+
try {
|
|
87
|
+
kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// ESRCH = no such process. Any other error (e.g. EPERM — exists but this
|
|
91
|
+
// process lacks permission to signal it) is treated as "still alive" —
|
|
92
|
+
// best-effort here means erring toward not silently dropping a real warning.
|
|
93
|
+
return (err as NodeJS.ErrnoException | undefined)?.code === 'EPERM';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface SessionLivenessCheck {
|
|
98
|
+
readonly live: boolean;
|
|
99
|
+
readonly pid: number | null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Best-effort: is `sessionId` apparently open in another still-running
|
|
104
|
+
* process right now? A missing, stale (older than LIVENESS_STALE_AFTER_MS),
|
|
105
|
+
* or unreadable marker all resolve to `{ live: false, pid: null }` — never a
|
|
106
|
+
* throw, never a block on the caller.
|
|
107
|
+
*/
|
|
108
|
+
export function checkSessionLiveness(
|
|
109
|
+
surface: SessionSurface,
|
|
110
|
+
sessionId: string,
|
|
111
|
+
opts: { readonly now?: () => number; readonly isPidAliveFn?: typeof isPidAlive } = {},
|
|
112
|
+
): SessionLivenessCheck {
|
|
113
|
+
const marker = readLivenessMarker(surface, sessionId);
|
|
114
|
+
if (!marker) return { live: false, pid: null };
|
|
115
|
+
const now = opts.now?.() ?? Date.now();
|
|
116
|
+
if (now - marker.updatedAt > LIVENESS_STALE_AFTER_MS) return { live: false, pid: null };
|
|
117
|
+
const aliveCheck = opts.isPidAliveFn ?? isPidAlive;
|
|
118
|
+
if (!aliveCheck(marker.pid)) return { live: false, pid: null };
|
|
119
|
+
return { live: true, pid: marker.pid };
|
|
120
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { writeLastSessionPointer } from '@/runtime/index.ts';
|
|
2
|
+
import type { SessionSurface } from '@/runtime/index.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Binds the SDK's two-argument `writeLastSessionPointer(sessionId, options?)`
|
|
6
|
+
* to one `SessionSurface`, returning a plain `(sessionId: string) => void`.
|
|
7
|
+
*
|
|
8
|
+
* Never pass the raw SDK function reference into a `(sessionId: string) =>
|
|
9
|
+
* void` slot. A function with an extra optional parameter is structurally
|
|
10
|
+
* assignable there, so it type-checks, but every downstream caller
|
|
11
|
+
* (bootstrap-shell.ts -> bootstrap-hook-bridge.ts's resume handler) invokes
|
|
12
|
+
* it with exactly one argument: `options` arrives as `undefined`, the legacy
|
|
13
|
+
* compat path's `requireWorkingDirectory(undefined)` throws, and
|
|
14
|
+
* `writeLastSessionPointer`'s own try/catch swallows that into a logged
|
|
15
|
+
* warning. The pointer file then silently never gets written after a resume,
|
|
16
|
+
* and the next launch's `--continue` / boot notice sees nothing. That was the
|
|
17
|
+
* live defect at bootstrap.ts's `createBootstrapShell({ writeLastSessionPointer })`
|
|
18
|
+
* call. See session-surface-pointer-journey.test.ts for the on-disk proof.
|
|
19
|
+
*/
|
|
20
|
+
export function bindWriteLastSessionPointerToSurface(surface: SessionSurface): (sessionId: string) => void {
|
|
21
|
+
return (sessionId: string): void => writeLastSessionPointer(sessionId, { surface });
|
|
22
|
+
}
|