@pellux/goodvibes-tui 1.19.5 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +143 -1613
  4. package/docs/README.md +4 -4
  5. package/docs/foundation-artifacts/operator-contract.json +1 -1
  6. package/package.json +7 -7
  7. package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
  8. package/src/cli/entrypoint.ts +5 -1
  9. package/src/cli/launch-auto-update.ts +63 -4
  10. package/src/cli/tui-startup.ts +131 -15
  11. package/src/config/surface.ts +21 -0
  12. package/src/config/tui-extension-settings.ts +9 -1
  13. package/src/core/bookmark-navigation.ts +44 -0
  14. package/src/core/conversation-line-cache.ts +87 -19
  15. package/src/core/conversation-rendering.ts +186 -28
  16. package/src/core/conversation-tool-groups.ts +152 -0
  17. package/src/core/conversation-types.ts +30 -3
  18. package/src/core/conversation.ts +17 -0
  19. package/src/core/rewind-turn-anchors.ts +12 -13
  20. package/src/core/session-recovery.ts +62 -7
  21. package/src/core/session-resume-core.ts +206 -0
  22. package/src/core/transcript-journal.ts +32 -6
  23. package/src/core/turn-event-wiring.ts +55 -12
  24. package/src/input/command-args-hint.ts +11 -2
  25. package/src/input/command-registry.ts +19 -0
  26. package/src/input/commands/checkpoint-runtime.ts +3 -3
  27. package/src/input/commands/health-runtime.ts +1 -3
  28. package/src/input/commands/local-runtime.ts +19 -2
  29. package/src/input/commands/rewind-runtime.ts +132 -1
  30. package/src/input/commands/runtime-services.ts +11 -1
  31. package/src/input/commands/secret-runtime.ts +1 -1
  32. package/src/input/commands/session-content.ts +9 -1
  33. package/src/input/commands/session-picker-filter.ts +28 -0
  34. package/src/input/commands/session-workflow.ts +62 -73
  35. package/src/input/commands/session.ts +36 -1
  36. package/src/input/commands/shell-core.ts +57 -5
  37. package/src/input/commands/update-runtime.ts +120 -4
  38. package/src/input/commands/websearch-runtime.ts +1 -1
  39. package/src/input/handler-content-actions.ts +63 -39
  40. package/src/input/handler-feed-routes.ts +17 -3
  41. package/src/input/handler-feed.ts +3 -0
  42. package/src/input/handler-interactions.ts +19 -23
  43. package/src/input/handler-modal-token-routes.ts +5 -0
  44. package/src/input/handler-types.ts +4 -1
  45. package/src/input/handler-ui-state.ts +6 -2
  46. package/src/input/handler.ts +19 -9
  47. package/src/input/search.ts +94 -2
  48. package/src/input/selection-modal-queue.ts +176 -0
  49. package/src/main.ts +55 -56
  50. package/src/panels/builtin/operations.ts +2 -1
  51. package/src/panels/provider-health-domains.ts +2 -2
  52. package/src/renderer/block-actions-overlay.ts +59 -0
  53. package/src/renderer/block-actions.ts +1 -3
  54. package/src/renderer/context-status-hint.ts +73 -5
  55. package/src/renderer/conversation-layout.ts +16 -0
  56. package/src/renderer/conversation-overlays.ts +6 -0
  57. package/src/renderer/help-overlay.ts +69 -53
  58. package/src/renderer/selection-modal-overlay.ts +66 -10
  59. package/src/renderer/shell-surface.ts +15 -0
  60. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  61. package/src/renderer/ui-factory.ts +1 -1
  62. package/src/runtime/bootstrap-command-context.ts +5 -1
  63. package/src/runtime/bootstrap-command-parts.ts +5 -2
  64. package/src/runtime/bootstrap-core.ts +3 -3
  65. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  66. package/src/runtime/bootstrap-shell.ts +31 -16
  67. package/src/runtime/bootstrap.ts +13 -18
  68. package/src/runtime/code-index-services.ts +19 -0
  69. package/src/runtime/durability-services.ts +11 -7
  70. package/src/runtime/index.ts +12 -1
  71. package/src/runtime/process-lifecycle.ts +63 -2
  72. package/src/runtime/recovery-autosave.ts +48 -0
  73. package/src/runtime/recovery-offer-wiring.ts +89 -0
  74. package/src/runtime/recovery-prompt.ts +312 -0
  75. package/src/runtime/resume-notice.ts +26 -21
  76. package/src/runtime/services.ts +14 -16
  77. package/src/runtime/session-liveness-marker.ts +120 -0
  78. package/src/runtime/session-pointer-surface.ts +22 -0
  79. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  80. package/src/runtime/session-storage-services.ts +41 -0
  81. package/src/runtime/workspace-checkpointing.ts +10 -2
  82. package/src/shell/recovery-input-helpers.ts +17 -98
  83. package/src/shell/retry-affordance.ts +123 -0
  84. package/src/shell/ui-openers.ts +9 -0
  85. package/src/utils/splash-lines.ts +1 -1
  86. package/src/version.ts +1 -1
@@ -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
- * - There is no bare `/resume` command and no `/sessions` command (a
14
- * pre-existing typo already lives in the splash's own hint at
15
- * utils/splash-lines.ts — out of scope here, splash stays byte-identical).
16
- * The real, working command is `/session resume <id|name>` — it always
17
- * requires an explicit target. So this notice advertises
18
- * `/session resume <id>` with the real last-session id substituted in,
19
- * never a bare `/resume`.
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
- // No bare `/resume` exists — /session resume always requires a target.
126
- if (hasSession) hints.push(`/session resume ${facts.lastSessionId} to continue`);
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
- readonly workingDirectory: string;
138
- readonly homeDirectory: string;
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, 'workingDirectory' | 'homeDirectory' | 'surfaceRoot' | 'sessionManager'>): { turnCount: number; lastSessionId: string } | null {
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);
@@ -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
- shellPaths.resolveProjectPath('tui', 'sessions', 'task-graph.json'),
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), workingDirectory, surfaceRoot: 'tui', homeDirectory, shellPaths, // + retention-sweep roots & live config watch (mirrors the SDK)
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
- workingDirectory,
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: 'tui',
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
- async rerootStores(newWorkingDir: string): Promise<void> {
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
+ }