@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.
- package/CHANGELOG.md +13 -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/bundle-command.ts +1 -1
- 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/management.ts +1 -1
- 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/active-model-identity.ts +153 -0
- package/src/core/conversation-line-cache.ts +1 -1
- package/src/core/conversation-rendering.ts +70 -24
- package/src/core/conversation-tool-groups.ts +23 -10
- package/src/core/conversation-types.ts +9 -0
- package/src/core/conversation-user-receipts.ts +42 -0
- package/src/core/conversation.ts +20 -17
- package/src/core/rewind-turn-anchors.ts +12 -13
- package/src/core/session-recovery.ts +59 -2
- package/src/core/session-resume-core.ts +206 -0
- package/src/core/stream-event-wiring.ts +174 -14
- package/src/core/system-message-router.ts +22 -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 +1 -1
- 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 +66 -61
- package/src/panels/builtin/operations.ts +2 -1
- package/src/panels/provider-health-domains.ts +2 -2
- package/src/{cli → providers}/provider-classification.ts +12 -0
- 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/header-line.ts +146 -0
- package/src/renderer/help-overlay.ts +69 -53
- package/src/renderer/selection-modal-overlay.ts +66 -10
- package/src/renderer/shell-surface.ts +51 -0
- package/src/renderer/tool-result-expanded-lines.ts +48 -0
- package/src/renderer/ui-factory.ts +26 -79
- 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 -0
- package/src/runtime/process-lifecycle.ts +63 -2
- package/src/runtime/recovery-autosave.ts +48 -0
- package/src/runtime/recovery-decisions.ts +126 -0
- package/src/runtime/recovery-offer-wiring.ts +89 -0
- package/src/runtime/recovery-prompt.ts +362 -0
- package/src/runtime/resume-notice.ts +28 -93
- 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 +7 -3
- 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
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
|
+
}
|
|
@@ -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
|
|
28
|
-
*
|
|
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 (
|
|
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
|
+
}
|
package/src/shell/ui-openers.ts
CHANGED
|
@@ -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 — /
|
|
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.
|
|
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
|