@pellux/goodvibes-tui 1.19.6 → 1.20.1

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 (94) hide show
  1. package/CHANGELOG.md +13 -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/bundle-command.ts +1 -1
  8. package/src/cli/ensure-goodvibes-gitignore.ts +16 -4
  9. package/src/cli/entrypoint.ts +5 -1
  10. package/src/cli/launch-auto-update.ts +63 -4
  11. package/src/cli/management.ts +1 -1
  12. package/src/cli/tui-startup.ts +131 -15
  13. package/src/config/surface.ts +21 -0
  14. package/src/config/tui-extension-settings.ts +9 -1
  15. package/src/core/active-model-identity.ts +153 -0
  16. package/src/core/conversation-line-cache.ts +1 -1
  17. package/src/core/conversation-rendering.ts +70 -24
  18. package/src/core/conversation-tool-groups.ts +23 -10
  19. package/src/core/conversation-types.ts +9 -0
  20. package/src/core/conversation-user-receipts.ts +42 -0
  21. package/src/core/conversation.ts +20 -17
  22. package/src/core/rewind-turn-anchors.ts +12 -13
  23. package/src/core/session-recovery.ts +59 -2
  24. package/src/core/session-resume-core.ts +206 -0
  25. package/src/core/stream-event-wiring.ts +174 -14
  26. package/src/core/system-message-router.ts +22 -0
  27. package/src/core/transcript-journal.ts +32 -6
  28. package/src/core/turn-event-wiring.ts +55 -12
  29. package/src/input/command-args-hint.ts +11 -2
  30. package/src/input/command-registry.ts +19 -0
  31. package/src/input/commands/checkpoint-runtime.ts +3 -3
  32. package/src/input/commands/health-runtime.ts +1 -3
  33. package/src/input/commands/local-runtime.ts +1 -1
  34. package/src/input/commands/rewind-runtime.ts +132 -1
  35. package/src/input/commands/runtime-services.ts +11 -1
  36. package/src/input/commands/secret-runtime.ts +1 -1
  37. package/src/input/commands/session-content.ts +9 -1
  38. package/src/input/commands/session-picker-filter.ts +28 -0
  39. package/src/input/commands/session-workflow.ts +62 -73
  40. package/src/input/commands/session.ts +36 -1
  41. package/src/input/commands/shell-core.ts +57 -5
  42. package/src/input/commands/update-runtime.ts +120 -4
  43. package/src/input/commands/websearch-runtime.ts +1 -1
  44. package/src/input/handler-content-actions.ts +63 -39
  45. package/src/input/handler-feed-routes.ts +17 -3
  46. package/src/input/handler-feed.ts +3 -0
  47. package/src/input/handler-interactions.ts +19 -23
  48. package/src/input/handler-modal-token-routes.ts +5 -0
  49. package/src/input/handler-types.ts +4 -1
  50. package/src/input/handler-ui-state.ts +6 -2
  51. package/src/input/handler.ts +19 -9
  52. package/src/input/search.ts +94 -2
  53. package/src/input/selection-modal-queue.ts +176 -0
  54. package/src/main.ts +66 -61
  55. package/src/panels/builtin/operations.ts +2 -1
  56. package/src/panels/provider-health-domains.ts +2 -2
  57. package/src/{cli → providers}/provider-classification.ts +12 -0
  58. package/src/renderer/block-actions-overlay.ts +59 -0
  59. package/src/renderer/block-actions.ts +1 -3
  60. package/src/renderer/context-status-hint.ts +73 -5
  61. package/src/renderer/conversation-layout.ts +16 -0
  62. package/src/renderer/conversation-overlays.ts +6 -0
  63. package/src/renderer/header-line.ts +146 -0
  64. package/src/renderer/help-overlay.ts +69 -53
  65. package/src/renderer/selection-modal-overlay.ts +66 -10
  66. package/src/renderer/shell-surface.ts +51 -0
  67. package/src/renderer/tool-result-expanded-lines.ts +48 -0
  68. package/src/renderer/ui-factory.ts +26 -79
  69. package/src/runtime/bootstrap-command-context.ts +5 -1
  70. package/src/runtime/bootstrap-command-parts.ts +5 -2
  71. package/src/runtime/bootstrap-core.ts +3 -3
  72. package/src/runtime/bootstrap-hook-bridge.ts +62 -44
  73. package/src/runtime/bootstrap-shell.ts +31 -16
  74. package/src/runtime/bootstrap.ts +13 -18
  75. package/src/runtime/code-index-services.ts +19 -0
  76. package/src/runtime/durability-services.ts +11 -7
  77. package/src/runtime/index.ts +12 -0
  78. package/src/runtime/process-lifecycle.ts +63 -2
  79. package/src/runtime/recovery-autosave.ts +48 -0
  80. package/src/runtime/recovery-decisions.ts +126 -0
  81. package/src/runtime/recovery-offer-wiring.ts +89 -0
  82. package/src/runtime/recovery-prompt.ts +362 -0
  83. package/src/runtime/resume-notice.ts +28 -93
  84. package/src/runtime/services.ts +14 -16
  85. package/src/runtime/session-liveness-marker.ts +120 -0
  86. package/src/runtime/session-pointer-surface.ts +22 -0
  87. package/src/runtime/session-resume-liveness-confirm.ts +84 -0
  88. package/src/runtime/session-storage-services.ts +41 -0
  89. package/src/runtime/workspace-checkpointing.ts +10 -2
  90. package/src/shell/recovery-input-helpers.ts +7 -3
  91. package/src/shell/retry-affordance.ts +123 -0
  92. package/src/shell/ui-openers.ts +9 -0
  93. package/src/utils/splash-lines.ts +1 -1
  94. package/src/version.ts +1 -1
@@ -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
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * session-resume-liveness-confirm.ts — the "this session is open somewhere
3
+ * else" check for the session-browser / panel resume seam.
4
+ *
5
+ * `/session resume <id>` already refuses to resume a session whose liveness
6
+ * marker points at another running process, and tells the operator to re-run
7
+ * with `--force`. The panel seam had no such check at all: picking a session
8
+ * from the browser forked another terminal's live state with no warning. That
9
+ * seam has no argv to carry a `--force`, so it asks the same question as a
10
+ * modal instead.
11
+ *
12
+ * Semantics are unchanged from the text-command check and stay best-effort: a
13
+ * missing, stale, or unreadable marker means "we can't tell", and the resume
14
+ * proceeds exactly as it always did. The marker belonging to THIS process is
15
+ * ignored too — re-resuming the session already open here is not a fork.
16
+ */
17
+ import { checkSessionLiveness } from './session-liveness-marker.ts';
18
+ import type { SessionSurface } from '@/runtime/index.ts';
19
+ import type { SelectionItem, SelectionResult } from '../input/selection-modal.ts';
20
+
21
+ export type LiveResumeSelectionOpener = (
22
+ title: string,
23
+ items: SelectionItem[],
24
+ opts: { preSelectId?: string; allowSearch?: boolean; primaryVerbLabel?: string } | undefined,
25
+ callback: (result: SelectionResult | null) => void,
26
+ ) => void;
27
+
28
+ /**
29
+ * The confirm modal's title. Short by necessity: the selection overlay
30
+ * truncates its title to the box width (selection-modal-overlay.ts), so
31
+ * anything the operator must actually read — the pid, and what resuming would
32
+ * do — belongs in a row's `detail`, which that overlay wraps instead.
33
+ */
34
+ export const LIVE_RESUME_CONFIRM_TITLE = 'Session open elsewhere';
35
+
36
+ export function buildLiveResumeConfirmItems(pid: number): SelectionItem[] {
37
+ return [
38
+ {
39
+ id: 'resume',
40
+ label: 'Resume anyway',
41
+ detail: `This session appears open in another terminal (pid ${pid}); resuming will fork its live state. Both terminals would hold their own copy of the conversation from this point on.`,
42
+ primaryAction: 'select',
43
+ },
44
+ {
45
+ id: 'cancel',
46
+ label: 'Cancel',
47
+ detail: 'Leave the session to the terminal that already has it open.',
48
+ primaryAction: 'select',
49
+ },
50
+ ];
51
+ }
52
+
53
+ export interface ConfirmLiveResumeDeps {
54
+ readonly surface: SessionSurface;
55
+ /** Late-bound: the shell builds its selection opener after the resume handler exists. */
56
+ readonly openSelection: () => LiveResumeSelectionOpener | undefined;
57
+ /** Injectable for tests; defaults to this process's own pid. */
58
+ readonly selfPid?: number;
59
+ }
60
+
61
+ /**
62
+ * Resolve true when the resume should proceed. Proceeds without asking
63
+ * whenever the liveness signal says there is nothing to warn about, or when
64
+ * no selection surface exists to ask through (headless hosts keep the
65
+ * pre-existing behavior rather than silently refusing).
66
+ *
67
+ * A dismissed modal resolves false: the safe answer to an unanswered "should
68
+ * I fork another terminal's state?" is no.
69
+ */
70
+ export async function confirmLiveResume(sessionId: string, deps: ConfirmLiveResumeDeps): Promise<boolean> {
71
+ const selfPid = deps.selfPid ?? process.pid;
72
+ const liveness = checkSessionLiveness(deps.surface, sessionId);
73
+ if (!liveness.live || liveness.pid === null || liveness.pid === selfPid) return true;
74
+ const open = deps.openSelection();
75
+ if (!open) return true;
76
+ return new Promise<boolean>((resolve) => {
77
+ open(
78
+ LIVE_RESUME_CONFIRM_TITLE,
79
+ buildLiveResumeConfirmItems(liveness.pid!),
80
+ { allowSearch: false, primaryVerbLabel: 'Choose' },
81
+ (result) => resolve(result?.item.id === 'resume'),
82
+ );
83
+ });
84
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * session-storage-services.ts — the declare-once storage handle and the
3
+ * session store built on it.
4
+ *
5
+ * Every path the app uses for session state (the sessions directory, the
6
+ * last-session pointer, crash-recovery snapshots, the checkpoint store, the
7
+ * transcript journal, the multi-instance liveness markers) derives from ONE
8
+ * `SessionSurface`, built here, once, at composition time.
9
+ *
10
+ * The reason it is one object rather than a scope argument repeated at each
11
+ * call site: when each site derived its own scope, they drifted. A resume
12
+ * wrote the last-session pointer with no scope at all — into the shared,
13
+ * unscoped `.goodvibes/` directory — while `--continue` and the boot notice
14
+ * read a scoped path under `.goodvibes/tui/`. Nothing errored; the pointer
15
+ * simply never existed where anyone looked. `createSessionSurface` also
16
+ * throws synchronously on a bad scope instead of silently resolving to the
17
+ * unscoped fallback on first use, so a mistake here fails at boot rather than
18
+ * six months later as missing state.
19
+ */
20
+ import { SessionManager } from '@pellux/goodvibes-sdk/platform/sessions';
21
+ import { createSessionSurface, type SessionSurface } from '@/runtime/index.ts';
22
+ import { GOODVIBES_TUI_SURFACE_ROOT } from '../config/surface.ts';
23
+
24
+ export interface SessionStorageServices {
25
+ readonly surface: SessionSurface;
26
+ readonly sessionManager: SessionManager;
27
+ }
28
+
29
+ export function createSessionStorageServices(input: {
30
+ readonly workingDirectory: string;
31
+ readonly homeDirectory: string;
32
+ }): SessionStorageServices {
33
+ // Also performs the SDK's one-time migration of pre-surface state (pointer
34
+ // copy-forward, journal moves) into the scoped location.
35
+ const surface = createSessionSurface({
36
+ surfaceRoot: GOODVIBES_TUI_SURFACE_ROOT,
37
+ workingDirectory: input.workingDirectory,
38
+ homeDirectory: input.homeDirectory,
39
+ });
40
+ return { surface, sessionManager: new SessionManager(input.workingDirectory, { surface }) };
41
+ }
@@ -1,7 +1,7 @@
1
1
  import { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
2
2
  import { withCheckpointGuardSettings } from '../config/tui-extension-settings.ts';
3
3
  import type { ConfigManager } from '../config/index.ts';
4
- import type { RuntimeEventBus } from '@/runtime/index.ts';
4
+ import type { RuntimeEventBus, SessionSurface } from '@/runtime/index.ts';
5
5
 
6
6
  /**
7
7
  * createWorkspaceCheckpointing — build the per-workspace checkpoint manager and
@@ -10,13 +10,21 @@ import type { RuntimeEventBus } from '@/runtime/index.ts';
10
10
  */
11
11
  export function createWorkspaceCheckpointing(opts: {
12
12
  workspaceRoot: string;
13
+ /**
14
+ * The app's declare-once storage handle. Passing it resolves the checkpoint
15
+ * git store to `surface.checkpointsDir` (`<workingDirectory>/.goodvibes/tui/
16
+ * checkpoints`) instead of the unscoped `<workspaceRoot>/.goodvibes/
17
+ * checkpoints` every product using this SDK would otherwise share. The SDK
18
+ * migrates an existing legacy store into the scoped location on first use.
19
+ */
20
+ surface: SessionSurface;
13
21
  runtimeBus: RuntimeEventBus;
14
22
  configManager: ConfigManager;
15
23
  }): WorkspaceCheckpointManager {
16
24
  // Checkpoint root-guard from the user's `checkpoints.*` config (docs/configuration.md;
17
25
  // inert until the platform SDK's checkpoint manager exposes the keys — see the reader's note).
18
26
  const manager = new WorkspaceCheckpointManager(
19
- withCheckpointGuardSettings({ workspaceRoot: opts.workspaceRoot, runtimeBus: opts.runtimeBus }, opts.configManager),
27
+ withCheckpointGuardSettings({ workspaceRoot: opts.workspaceRoot, surface: opts.surface, runtimeBus: opts.runtimeBus }, opts.configManager),
20
28
  );
21
29
  // Fire-and-forget: subscriptions go live immediately if init() succeeds.
22
30
  // If it rejects, WorkspaceCheckpointManager caches that rejection on
@@ -24,11 +24,15 @@ export interface ErrorAffordanceDeps {
24
24
  /**
25
25
  * Handle one keypress while the error-retry affordance is active.
26
26
  * 'r' retries on the current provider when armed; 'm' opens the model
27
- * picker. Returns true when the key was consumed; any other key returns
28
- * false so the caller routes it as normal input.
27
+ * picker — also gated on armed, since switching models here only makes
28
+ * sense in service of the same retry this affordance is offering, not as a
29
+ * general-purpose model-picker shortcut. Returns true when the key was
30
+ * consumed; any other key (or 'm'/'r' while unarmed) returns false so the
31
+ * caller routes it as normal input.
29
32
  */
30
33
  export function handleErrorAffordanceKey(data: string, deps: ErrorAffordanceDeps): boolean {
31
- if (data === 'r' && deps.retryArmed) {
34
+ if (!deps.retryArmed) return false;
35
+ if (data === 'r') {
32
36
  deps.retry();
33
37
  deps.render();
34
38
  return true;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * retry-affordance.ts — the one-key retry/switch-model affordance shown
3
+ * right after a user-visible TURN_ERROR: 'r' re-submits on the current
4
+ * provider, 'm' opens the model picker, any other key disarms it silently.
5
+ *
6
+ * Rendered as a transient FOOTER status line (see retryAffordanceHint,
7
+ * wired into ShellFooterBuildOptions.retryHint in shell-surface.ts) rather
8
+ * than a permanent transcript message — it disappears the moment it's
9
+ * disarmed, instead of sitting in the scrollback claiming an affordance
10
+ * that no longer does anything. Extracted from main.ts to keep the
11
+ * entrypoint under the architecture line cap.
12
+ *
13
+ * The affordance is also time-bounded: arming starts a disarm timer so a
14
+ * stray 'r' hours later can never fire a real, paid retry against the
15
+ * provider. The timer's window is exactly what the footer hint's visibility
16
+ * implies — when it fires, the state disarms AND retryAffordanceHint(state)
17
+ * goes back to null in the same tick, so the key dies at the same moment
18
+ * the hint disappears.
19
+ */
20
+
21
+ /** How long the affordance stays armed with no keypress before it silently expires. */
22
+ export const RETRY_AFFORDANCE_WINDOW_MS = 60_000;
23
+
24
+ /** Timer primitives injectable for tests, so arming doesn't require a real 60s wait. */
25
+ export interface RetryAffordanceSchedule {
26
+ readonly setTimeout: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
27
+ readonly clearTimeout: (handle: ReturnType<typeof setTimeout>) => void;
28
+ }
29
+
30
+ const REAL_SCHEDULE: RetryAffordanceSchedule = {
31
+ setTimeout: (cb, ms) => setTimeout(cb, ms),
32
+ clearTimeout: (handle) => clearTimeout(handle),
33
+ };
34
+
35
+ export interface RetryAffordanceState {
36
+ armed: boolean;
37
+ exhausted: boolean;
38
+ /** Internal: the pending disarm timer's handle, or null while unarmed. */
39
+ timer: ReturnType<typeof setTimeout> | null;
40
+ /** Internal: injected timer primitives — real setTimeout/clearTimeout in production. */
41
+ readonly schedule: RetryAffordanceSchedule;
42
+ /** Internal: the disarm window in ms. */
43
+ readonly windowMs: number;
44
+ /** Internal: invoked after the timer disarms the state, so the render loop can repaint the footer. */
45
+ readonly onExpire?: () => void;
46
+ }
47
+
48
+ export interface CreateRetryAffordanceStateOptions {
49
+ /** Overridable for tests; defaults to RETRY_AFFORDANCE_WINDOW_MS (60s). */
50
+ readonly windowMs?: number;
51
+ /** Overridable for tests so the disarm timer doesn't require a real 60s wait. */
52
+ readonly schedule?: RetryAffordanceSchedule;
53
+ /** Called once the window elapses with no keypress and the state has been disarmed — main.ts hooks this to its render loop so the footer hint clears the instant the key goes dead. */
54
+ readonly onExpire?: () => void;
55
+ }
56
+
57
+ export function createRetryAffordanceState(options: CreateRetryAffordanceStateOptions = {}): RetryAffordanceState {
58
+ return {
59
+ armed: false,
60
+ exhausted: false,
61
+ timer: null,
62
+ schedule: options.schedule ?? REAL_SCHEDULE,
63
+ windowMs: options.windowMs ?? RETRY_AFFORDANCE_WINDOW_MS,
64
+ onExpire: options.onExpire,
65
+ };
66
+ }
67
+
68
+ function clearPendingTimer(state: RetryAffordanceState): void {
69
+ if (state.timer !== null) {
70
+ state.schedule.clearTimeout(state.timer);
71
+ state.timer = null;
72
+ }
73
+ }
74
+
75
+ /** Arm the affordance — call only when a retry is actually possible (a live retryCtx).
76
+ * Starts (or restarts, if already armed) the disarm timer. */
77
+ export function armRetryAffordance(state: RetryAffordanceState, exhausted: boolean): void {
78
+ clearPendingTimer(state);
79
+ state.armed = true;
80
+ state.exhausted = exhausted;
81
+ const timer = state.schedule.setTimeout(() => {
82
+ state.timer = null;
83
+ state.armed = false;
84
+ state.onExpire?.();
85
+ }, state.windowMs);
86
+ // Never let this timer hold the process open — a background retry-window
87
+ // countdown is not a reason to keep the event loop alive.
88
+ (timer as unknown as { unref?: () => void }).unref?.();
89
+ state.timer = timer;
90
+ }
91
+
92
+ /** Disarm the affordance — any key other than the ones it recognizes disarms it,
93
+ * and cancels the pending disarm timer so it never fires on a state that's
94
+ * already been reset (or re-armed by a subsequent error). */
95
+ export function disarmRetryAffordance(state: RetryAffordanceState): void {
96
+ clearPendingTimer(state);
97
+ state.armed = false;
98
+ }
99
+
100
+ /** The transient footer hint text while armed; null once disarmed. */
101
+ export function retryAffordanceHint(state: RetryAffordanceState): string | null {
102
+ if (!state.armed) return null;
103
+ return state.exhausted
104
+ ? '[Retry] r retry same provider · m switch model'
105
+ : '[Retry] r retry · m switch model';
106
+ }
107
+
108
+ /** Arm the affordance whenever a user-visible error surfaces AND a retry is
109
+ * actually possible — wired here (not inline in main.ts) to keep the
110
+ * entrypoint under the architecture line cap. */
111
+ export function wireRetryAffordanceOnError(
112
+ onErrorSurfaced: (cb: (exhausted: boolean) => void) => void,
113
+ state: RetryAffordanceState,
114
+ hasRetryCtx: () => boolean,
115
+ render: () => void,
116
+ ): void {
117
+ onErrorSurfaced((exhausted) => {
118
+ if (hasRetryCtx()) {
119
+ armRetryAffordance(state, exhausted);
120
+ render();
121
+ }
122
+ });
123
+ }
@@ -369,6 +369,15 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
369
369
  render();
370
370
  };
371
371
 
372
+ // Mirrors the Ctrl+F chord's own toggle exactly (handler-shortcuts.ts's
373
+ // 'search' case) — the transcript search overlay isn't tracked on the
374
+ // modal stack, so this doesn't call modalOpened either.
375
+ commandContext.openTranscriptSearch = () => {
376
+ if (input.searchManager.active) input.searchManager.close();
377
+ else input.searchManager.open();
378
+ render();
379
+ };
380
+
372
381
  commandContext.openShortcutsOverlay = () => {
373
382
  if (!input.shortcutsOverlayActive) input.modalOpened('shortcuts');
374
383
  input.shortcutsOverlayActive = !input.shortcutsOverlayActive;
@@ -85,7 +85,7 @@ export function getSplashLines(columns: number, opts: SplashOptions = {}): strin
85
85
 
86
86
  // Last-session resume pointer (readLastSessionPointer result), when recorded.
87
87
  if (opts.lastSessionId) {
88
- lines.push(metaLine(`↩ resume last session — /sessions resume ${opts.lastSessionId}`, columns));
88
+ lines.push(metaLine(`↩ resume last session — /resume ${opts.lastSessionId}`, columns));
89
89
  }
90
90
 
91
91
  lines.push('');
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.19.6';
9
+ let _version = '1.20.1';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  // Only trust a version read from OUR OWN package.json. A Bun single-file