@pellux/goodvibes-tui 1.13.0 → 1.14.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 (157) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +6 -6
  3. package/bin/goodvibes +6 -0
  4. package/bin/goodvibes-daemon +6 -0
  5. package/bin/launcher-support.js +34 -1
  6. package/docs/foundation-artifacts/operator-contract.json +8076 -2047
  7. package/package.json +15 -11
  8. package/scripts/postinstall.js +73 -48
  9. package/src/cli/bundle-command.ts +5 -5
  10. package/src/cli/completion.ts +1 -1
  11. package/src/cli/completions/generate.ts +15 -3
  12. package/src/cli/doctor.ts +385 -0
  13. package/src/cli/entrypoint.ts +72 -1
  14. package/src/cli/help.ts +29 -9
  15. package/src/cli/hooks-command.ts +74 -0
  16. package/src/cli/hooks-report.ts +167 -0
  17. package/src/cli/index.ts +3 -0
  18. package/src/cli/management.ts +13 -1
  19. package/src/cli/parser.ts +10 -2
  20. package/src/cli/plugin-bundles-command.ts +228 -0
  21. package/src/cli/plugin-command.ts +179 -0
  22. package/src/cli/service-posture.ts +10 -0
  23. package/src/cli/status.ts +275 -2
  24. package/src/cli/tui-startup.ts +119 -1
  25. package/src/cli/types.ts +3 -1
  26. package/src/config/index.ts +16 -0
  27. package/src/config/tui-extension-settings.ts +169 -0
  28. package/src/core/approval-alert.ts +15 -2
  29. package/src/core/compaction-receipt.ts +127 -0
  30. package/src/core/model-routing-chip.ts +82 -0
  31. package/src/core/permission-mode.ts +89 -0
  32. package/src/core/rewind-receipt.ts +83 -0
  33. package/src/core/rewind-turn-anchors.ts +160 -0
  34. package/src/core/scriptable-statusline.ts +141 -0
  35. package/src/core/session-auto-titler.ts +110 -0
  36. package/src/core/stream-event-wiring.ts +20 -0
  37. package/src/core/system-message-router.ts +1 -1
  38. package/src/core/terminal-notifier.ts +102 -0
  39. package/src/core/turn-event-wiring.ts +80 -1
  40. package/src/input/command-reference.ts +96 -0
  41. package/src/input/command-registry.ts +25 -0
  42. package/src/input/commands/channel-pairing.ts +245 -0
  43. package/src/input/commands/channel-runtime.ts +152 -6
  44. package/src/input/commands/checkin-runtime.ts +89 -0
  45. package/src/input/commands/checkpoint-runtime.ts +7 -179
  46. package/src/input/commands/ci-runtime.ts +207 -0
  47. package/src/input/commands/cost-attribution-format.ts +52 -0
  48. package/src/input/commands/cost-runtime.ts +44 -4
  49. package/src/input/commands/editor-runtime.ts +22 -0
  50. package/src/input/commands/flags-runtime.ts +328 -0
  51. package/src/input/commands/health-metrics-format.ts +67 -0
  52. package/src/input/commands/health-runtime.ts +55 -1
  53. package/src/input/commands/memory.ts +23 -0
  54. package/src/input/commands/operator-rpc.ts +95 -0
  55. package/src/input/commands/palette-runtime.ts +22 -0
  56. package/src/input/commands/permissions-provenance.ts +264 -0
  57. package/src/input/commands/permissions-runtime.ts +21 -0
  58. package/src/input/commands/planning-runtime.ts +28 -1
  59. package/src/input/commands/platform-access-runtime.ts +2 -97
  60. package/src/input/commands/principals-runtime.ts +212 -0
  61. package/src/input/commands/product-runtime.ts +43 -3
  62. package/src/input/commands/recall-files-config.ts +54 -0
  63. package/src/input/commands/recall-files-git.ts +57 -0
  64. package/src/input/commands/recall-files.ts +185 -0
  65. package/src/input/commands/recall-query.ts +8 -3
  66. package/src/input/commands/recall-review.ts +2 -2
  67. package/src/input/commands/recall-shared.ts +13 -0
  68. package/src/input/commands/relay-runtime.ts +86 -0
  69. package/src/input/commands/review-runtime.ts +160 -0
  70. package/src/input/commands/rewind-runtime.ts +362 -0
  71. package/src/input/commands/schedule-nl.ts +147 -0
  72. package/src/input/commands/schedule-runtime.ts +29 -10
  73. package/src/input/commands/secret-runtime.ts +55 -0
  74. package/src/input/commands/session-content.ts +19 -8
  75. package/src/input/commands/session-workflow.ts +10 -0
  76. package/src/input/commands/update-runtime.ts +363 -0
  77. package/src/input/commands/workstream-attempt-dependents.ts +103 -0
  78. package/src/input/commands/workstream-attempts.ts +244 -0
  79. package/src/input/commands/workstream-runtime.ts +164 -26
  80. package/src/input/commands/worktree-runtime.ts +56 -2
  81. package/src/input/commands.ts +135 -80
  82. package/src/input/composer-capture.ts +67 -0
  83. package/src/input/composer-editor.ts +134 -0
  84. package/src/input/concealed-input.ts +94 -0
  85. package/src/input/exec-env-scrub-config.ts +84 -0
  86. package/src/input/feature-unit-layout.ts +142 -0
  87. package/src/input/feed-context-factory.ts +2 -0
  88. package/src/input/handler-feed-routes.ts +17 -0
  89. package/src/input/handler-feed.ts +3 -0
  90. package/src/input/handler-modal-routes.ts +5 -2
  91. package/src/input/handler-shortcuts.ts +37 -0
  92. package/src/input/handler.ts +24 -2
  93. package/src/input/keybindings.ts +11 -2
  94. package/src/input/onboarding/onboarding-feature-units.ts +536 -0
  95. package/src/input/onboarding/onboarding-wizard-apply.ts +40 -6
  96. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +324 -629
  97. package/src/input/onboarding/onboarding-wizard-steps.ts +5 -5
  98. package/src/input/onboarding/onboarding-wizard-types.ts +6 -0
  99. package/src/input/relay-settings-descriptions.ts +35 -0
  100. package/src/input/sandbox-exec-config.ts +107 -0
  101. package/src/input/settings-modal-adjustment.ts +4 -7
  102. package/src/input/settings-modal-data.ts +178 -8
  103. package/src/input/settings-modal-mutations.ts +41 -13
  104. package/src/input/settings-modal-types.ts +42 -5
  105. package/src/input/settings-modal.ts +67 -15
  106. package/src/input/submission-router.ts +4 -1
  107. package/src/input/worktree-setup-config.ts +93 -0
  108. package/src/main.ts +43 -43
  109. package/src/panels/builtin/development.ts +14 -0
  110. package/src/panels/diff-review-model.ts +189 -0
  111. package/src/panels/diff-review-panel.ts +395 -0
  112. package/src/panels/fleet-panel-format.ts +114 -2
  113. package/src/panels/fleet-panel.ts +65 -78
  114. package/src/panels/fleet-read-model.ts +91 -11
  115. package/src/panels/fleet-stop.ts +19 -4
  116. package/src/panels/modals/memory-modal.ts +11 -2
  117. package/src/permissions/hunk-selection.ts +86 -5
  118. package/src/permissions/prompt.ts +108 -7
  119. package/src/permissions/sandbox-exec-gate.ts +162 -0
  120. package/src/renderer/settings-modal-helpers.ts +21 -1
  121. package/src/renderer/settings-modal.ts +70 -44
  122. package/src/renderer/shell-surface.ts +23 -1
  123. package/src/renderer/system-message.ts +4 -0
  124. package/src/renderer/tool-result-summary.ts +95 -5
  125. package/src/renderer/ui-factory.ts +22 -0
  126. package/src/runtime/bootstrap-command-context.ts +15 -1
  127. package/src/runtime/bootstrap-command-parts.ts +14 -2
  128. package/src/runtime/bootstrap-core.ts +38 -38
  129. package/src/runtime/bootstrap-notifier-sync.ts +41 -0
  130. package/src/runtime/bootstrap-shell.ts +8 -0
  131. package/src/runtime/bootstrap.ts +21 -23
  132. package/src/runtime/context-accounting-source.ts +103 -0
  133. package/src/runtime/conversation-rewind-port.ts +130 -0
  134. package/src/runtime/fleet-needs-input-push.ts +70 -0
  135. package/src/runtime/install-self-check-startup.ts +36 -0
  136. package/src/runtime/install-self-check.ts +149 -0
  137. package/src/runtime/onboarding/fast-path.ts +93 -0
  138. package/src/runtime/process-lifecycle.ts +32 -24
  139. package/src/runtime/relay-reachability-bridge.ts +53 -0
  140. package/src/runtime/release-artifacts.ts +79 -0
  141. package/src/runtime/services.ts +38 -37
  142. package/src/runtime/trust/workspace-registration.ts +184 -0
  143. package/src/runtime/trust/workspace-trust.ts +202 -0
  144. package/src/runtime/ui-services.ts +2 -1
  145. package/src/runtime/update-check.ts +113 -0
  146. package/src/runtime/workspace-checkpointing.ts +32 -0
  147. package/src/runtime/workstream-attempts-validation.ts +77 -0
  148. package/src/runtime/workstream-draft-edits.ts +95 -0
  149. package/src/runtime/workstream-draft-store.ts +101 -0
  150. package/src/runtime/workstream-draft-types.ts +42 -0
  151. package/src/runtime/workstream-services.ts +70 -39
  152. package/src/shell/blocking-input.ts +29 -0
  153. package/src/shell/ui-openers.ts +65 -0
  154. package/src/verification/verification-ledger.ts +56 -2
  155. package/src/version.ts +1 -1
  156. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +0 -130
  157. package/src/runtime/render-scheduler.ts +0 -90
@@ -13,45 +13,37 @@ import { join } from 'node:path';
13
13
  import { Orchestrator, type OrchestratorUserInputOptions } from '../core/orchestrator.ts';
14
14
  import { AcpManager } from '@pellux/goodvibes-sdk/platform/acp';
15
15
  import { getTierPromptSupplement, getTierForContextWindow } from '@pellux/goodvibes-sdk/platform/providers';
16
- import { logger } from '@pellux/goodvibes-sdk/platform/utils';
16
+ import { logger, summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
17
17
  import type { PermissionRequestHandler } from '@pellux/goodvibes-sdk/platform/permissions';
18
18
  import type { CommandContext } from '../input/command-registry.ts';
19
19
  import type { InputHistory } from '../input/input-history.ts';
20
- import type { GitStatusProvider } from '../renderer/git-status.ts';
21
- import type { GitHeaderInfo } from '../renderer/git-status.ts';
20
+ import type { GitStatusProvider, GitHeaderInfo } from '../renderer/git-status.ts';
22
21
  import type { SelectionManager } from '../input/selection.ts';
23
22
  import type { Compositor } from '../renderer/compositor.ts';
24
23
 
25
24
  import type { RuntimeContext, BootstrapOptions } from './context.ts';
26
- import { shutdownRuntime, fireSessionStart, saveSession } from '@/runtime/index.ts';
27
- import { createTaskManager } from '@/runtime/index.ts';
28
- import { OpsControlPlane } from '@/runtime/index.ts';
29
- import { AcpTaskAdapter } from '@/runtime/index.ts';
30
25
  import type { SystemMessageRouter } from '../core/system-message-router.ts';
31
- import { emitSessionReady, emitSessionStarted } from '@/runtime/index.ts';
32
26
  import {
33
- loadLastConversation,
34
- writeLastSessionPointer,
27
+ shutdownRuntime, fireSessionStart, saveSession, createTaskManager, OpsControlPlane, AcpTaskAdapter,
28
+ emitSessionReady, emitSessionStarted, loadLastConversation, writeLastSessionPointer,
29
+ scheduleBackgroundMcpDiscovery, startBackgroundProviderRegistration, restoreSavedModel, startExternalServices,
30
+ type ExternalServicesHandle, type HostServiceStatus, createHttpTransport, createDeferredStartupCoordinator,
35
31
  } from '@/runtime/index.ts';
36
- import { scheduleBackgroundMcpDiscovery, startBackgroundProviderRegistration } from '@/runtime/index.ts';
37
- import { restoreSavedModel } from '@/runtime/index.ts';
38
- import { startExternalServices, type ExternalServicesHandle, type HostServiceStatus } from '@/runtime/index.ts';
39
- import { createHttpTransport } from '@/runtime/index.ts';
40
32
  import { foldLegacySpineStore, deriveSpineFooterStatus } from '@pellux/goodvibes-sdk/platform/runtime/session-spine';
41
33
  import { createTuiSpineTransport, type SpineSessionsClient } from './session-spine-transport.ts';
42
34
  import { syncMemorySpineToHostStatus, type MemorySpineActiveRef } from './memory-spine-transport.ts';
43
35
  import { pruneStaleOperatorTokens } from '@pellux/goodvibes-sdk/platform/pairing';
44
36
  import { resolveDaemonCompanionToken, workspaceOperatorTokenCandidates } from './operator-token-cleanup.ts';
45
37
  import type { UiRuntimeServices } from './ui-services.ts';
46
- import { createDeferredStartupCoordinator } from '@/runtime/index.ts';
47
38
  import { initializeBootstrapCore } from './bootstrap-core.ts';
48
39
  import { createBootstrapShell } from './bootstrap-shell.ts';
49
40
  import { announceResumeState } from './resume-notice.ts';
41
+ import { announceInstallSelfCheck } from './install-self-check-startup.ts';
50
42
  import { buildSharedOrchestratorCoreServices, refreshMemoryRecallSnapshot } from './orchestrator-core-services.ts';
51
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
52
- import { DaemonServer } from '@pellux/goodvibes-sdk/platform/daemon';
53
- import { HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
43
+ import { createContextAccountingSource } from './context-accounting-source.ts';
44
+ import { DaemonServer, HttpListener } from '@pellux/goodvibes-sdk/platform/daemon';
54
45
  import { createSafeHostServeFactory } from '../daemon/safe-serve.ts';
46
+ import { buildRelayExternalServiceMethods } from './relay-reachability-bridge.ts';
55
47
  import { startMcpConfigAutoReload } from '../mcp/runtime-reload.ts';
56
48
 
57
49
  type ExternalServiceFactories = NonNullable<Parameters<typeof startExternalServices>[4]>;
@@ -253,6 +245,13 @@ export async function bootstrapRuntime(
253
245
  });
254
246
  conversation.setSessionLineageTracker(services.sessionLineageTracker);
255
247
 
248
+ // Bind context_accounting's session source to the live Orchestrator (see context-accounting-source.ts).
249
+ const contextAccountingSourceHandle = createContextAccountingSource({
250
+ orchestrator, providerRegistry, sessionLineageTracker: services.sessionLineageTracker, runtimeBus, sessionId: runtime.sessionId,
251
+ });
252
+ services.contextAccountingHolder.setSource(contextAccountingSourceHandle.source);
253
+ bootstrapUnsubs.push(contextAccountingSourceHandle.dispose);
254
+
256
255
  const acpManager = new AcpManager({
257
256
  requestPermission: (request) => permissionPromptRef.requestPermission(request),
258
257
  runtimeBus,
@@ -317,9 +316,8 @@ export async function bootstrapRuntime(
317
316
  }).catch(() => {
318
317
  // Best-effort — never let the resume notice block or crash boot.
319
318
  });
320
- const gitStatusProvider = shell.gitStatusProvider;
321
- const inputHistory = shell.inputHistory;
322
- const lastGitInfoRef = shell.lastGitInfoRef;
319
+ announceInstallSelfCheck(systemMessageRouter);
320
+ const { gitStatusProvider, inputHistory, lastGitInfoRef } = shell;
323
321
  // W1.6 FIX 2: dispose the header's live-repo-state poll (git-status.ts
324
322
  // startPolling) on shutdown, same pattern as acpTaskSyncInterval above.
325
323
  bootstrapUnsubs.push(() => gitStatusProvider.stopPolling());
@@ -494,8 +492,7 @@ export async function bootstrapRuntime(
494
492
  });
495
493
 
496
494
  let externalServices: ExternalServicesHandle = {
497
- daemonServer: null,
498
- httpListener: null,
495
+ daemonServer: null, httpListener: null,
499
496
  daemonStatus: createPendingServiceStatus('daemon'),
500
497
  httpListenerStatus: createPendingServiceStatus('httpListener'),
501
498
  listRecentControlPlaneEvents: () => [],
@@ -506,6 +503,7 @@ export async function bootstrapRuntime(
506
503
  externalServices: NonNullable<typeof uiServices.platform.externalServices>;
507
504
  };
508
505
  platformExternalServices.externalServices = {
506
+ ...buildRelayExternalServiceMethods(() => externalServices.daemonServer),
509
507
  inspect: inspectExternalServices,
510
508
  restart: async () => {
511
509
  if (externalServicesPromise) {
@@ -0,0 +1,103 @@
1
+ import type { Orchestrator } from '../core/orchestrator.ts';
2
+ import type { ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
3
+ import type { SessionLineageTracker } from '@pellux/goodvibes-sdk/platform/core';
4
+ import type { ContextAccountingSource } from '@pellux/goodvibes-sdk/platform/tools';
5
+
6
+ /**
7
+ * Minimal runtime-bus surface this module needs: subscribe by event type.
8
+ * A narrow structural type (not the full RuntimeEventBus) so this module
9
+ * doesn't pull in `runtime/index.ts` — bootstrap.ts imports this module
10
+ * directly, and index.ts re-exports bootstrap.ts, so a barrel import here
11
+ * would be a 3-file cycle (see check-architecture.ts's cycle detector).
12
+ */
13
+ export interface CompactionActivityBus {
14
+ on(eventType: string, callback: () => void): () => void;
15
+ }
16
+
17
+ /**
18
+ * Builds the interactive session's ContextAccountingSource — the honest
19
+ * backing for the SDK's `context_accounting` tool. Bound onto
20
+ * `RuntimeServices.contextAccountingHolder` once, at bootstrap (see
21
+ * bootstrap.ts, right after the live Orchestrator is constructed).
22
+ *
23
+ * Every field here reads a REAL, already-tracked value:
24
+ * - getTurnInjections() → Orchestrator.getTurnInjections() (the same bounded
25
+ * ring `/recall injections` renders from).
26
+ * - getTokenState() → Orchestrator.usage / lastInputTokens (the same
27
+ * counters the footer's context bar and CostTrackerPanel read), plus the
28
+ * current model's context window from the provider registry.
29
+ * - getCompactionState() → compactionCount from SessionLineageTracker (the
30
+ * same counter session-maintenance and /compact read); isCompacting is
31
+ * tracked from real 'compaction' domain lifecycle events on the runtime
32
+ * bus, since the Orchestrator has no public accessor for it — NOT
33
+ * fabricated. Without a runtime bus (headless/test callers), isCompacting
34
+ * honestly stays false rather than guessing.
35
+ */
36
+ export interface ContextAccountingSourceDeps {
37
+ readonly orchestrator: Pick<Orchestrator, 'getTurnInjections' | 'usage' | 'lastInputTokens'>;
38
+ readonly providerRegistry: Pick<ProviderRegistry, 'getCurrentModel' | 'getContextWindowForModel'>;
39
+ readonly sessionLineageTracker: Pick<SessionLineageTracker, 'getCompactionCount'>;
40
+ readonly runtimeBus?: CompactionActivityBus | null | undefined;
41
+ readonly sessionId: string;
42
+ /** Human-readable scope label, e.g. "main session". Defaults to 'main session'. */
43
+ readonly scope?: string | undefined;
44
+ }
45
+
46
+ /** Events that mark a compaction run starting. */
47
+ const COMPACTION_START_EVENT_TYPES = [
48
+ 'COMPACTION_AUTOCOMPACT',
49
+ 'COMPACTION_REACTIVE',
50
+ 'COMPACTION_MICROCOMPACT',
51
+ 'COMPACTION_COLLAPSE',
52
+ ] as const;
53
+
54
+ /** Events that mark a compaction run ending (successfully, rejected, or failed). */
55
+ const COMPACTION_END_EVENT_TYPES = [
56
+ 'COMPACTION_DONE',
57
+ 'COMPACTION_FAILED',
58
+ 'COMPACTION_RECEIPT',
59
+ ] as const;
60
+
61
+ export interface ContextAccountingSourceHandle {
62
+ readonly source: ContextAccountingSource;
63
+ /** Unsubscribe the compaction-activity listeners. Call on session teardown. */
64
+ readonly dispose: () => void;
65
+ }
66
+
67
+ export function createContextAccountingSource(deps: ContextAccountingSourceDeps): ContextAccountingSourceHandle {
68
+ let isCompacting = false;
69
+ const unsubs: Array<() => void> = [];
70
+ if (deps.runtimeBus) {
71
+ const bus = deps.runtimeBus;
72
+ for (const type of COMPACTION_START_EVENT_TYPES) {
73
+ unsubs.push(bus.on(type, () => { isCompacting = true; }));
74
+ }
75
+ for (const type of COMPACTION_END_EVENT_TYPES) {
76
+ unsubs.push(bus.on(type, () => { isCompacting = false; }));
77
+ }
78
+ }
79
+
80
+ const source: ContextAccountingSource = {
81
+ scope: deps.scope ?? 'main session',
82
+ sessionId: deps.sessionId,
83
+ getTurnInjections: () => deps.orchestrator.getTurnInjections(),
84
+ getTokenState: () => {
85
+ const currentModel = deps.providerRegistry.getCurrentModel();
86
+ const contextWindow = deps.providerRegistry.getContextWindowForModel(currentModel);
87
+ return {
88
+ measured: { ...deps.orchestrator.usage },
89
+ lastInputTokens: deps.orchestrator.lastInputTokens,
90
+ contextWindow: contextWindow > 0 ? contextWindow : null,
91
+ };
92
+ },
93
+ getCompactionState: () => ({
94
+ isCompacting,
95
+ compactionCount: deps.sessionLineageTracker.getCompactionCount(),
96
+ }),
97
+ };
98
+
99
+ return {
100
+ source,
101
+ dispose: () => { for (const unsub of unsubs) unsub(); },
102
+ };
103
+ }
@@ -0,0 +1,130 @@
1
+ // ---------------------------------------------------------------------------
2
+ // conversation-rewind-port.ts — the TUI's ConversationManager as the SDK unified
3
+ // rewind service's RewindConversationPort.
4
+ //
5
+ // The SDK's UnifiedRewindService (platform/rewind) joins files rewind (workspace
6
+ // checkpoints) with conversation rewind through two ports. The conversation port
7
+ // is "a daemon-hosted mutable conversation store": preview() reports how many
8
+ // messages would truncate to a recorded turn boundary, and rewind() performs the
9
+ // truncation and captures the pre-/post-truncation snapshots so the reversal can
10
+ // be undone and re-applied. The truncation boundary is the message count recorded
11
+ // for the anchor's turnId at TURN_COMPLETED (rewind-turn-anchors.ts) — the same
12
+ // join key files rewind uses against the workspace checkpoint.
13
+ //
14
+ // This module is the single implementation of that port for the TUI, consumed by
15
+ // BOTH the in-process /rewind command (bound to one session's conversation) and
16
+ // the composed daemon's rewind.plan/apply verbs (resolving the live conversation
17
+ // per anchor.sessionId from the registry below), so the truncation semantics
18
+ // cannot drift between the two surfaces.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import type {
22
+ RewindAnchor,
23
+ RewindConversationOutcome,
24
+ RewindConversationPort,
25
+ RewindConversationPreview,
26
+ } from '@pellux/goodvibes-sdk/platform/rewind';
27
+ import type { ConversationManager } from '../core/conversation.ts';
28
+ import { resolveTurnAnchor } from '../core/rewind-turn-anchors.ts';
29
+
30
+ type ConversationJson = Parameters<ConversationManager['fromJSON']>[0];
31
+
32
+ /** The port plus the reversal accessors the TUI-side undo/redo needs. */
33
+ export interface ConversationRewindPort extends RewindConversationPort {
34
+ /** Restore the pre-truncation conversation (the /undo direction). */
35
+ restoreBefore(undoSnapshotId: string): boolean;
36
+ /** Restore the post-truncation conversation (the /redo direction). */
37
+ restoreAfter(undoSnapshotId: string): boolean;
38
+ }
39
+
40
+ /** One truncation's captured state — the target conversation and its snapshots. */
41
+ interface SnapshotPair {
42
+ readonly conv: ConversationManager;
43
+ readonly before: ConversationJson;
44
+ readonly after: ConversationJson;
45
+ }
46
+
47
+ /**
48
+ * Build a conversation rewind port. `resolveConversation` maps an anchor's
49
+ * sessionId to the live ConversationManager — for the single-session /rewind
50
+ * command it returns the one bound conversation; for the daemon it looks the
51
+ * session up in the live registry. A null resolution means no live conversation
52
+ * for that session, reported as "nothing to drop" rather than a fabricated count.
53
+ */
54
+ export function createConversationRewindPort(
55
+ resolveConversation: (sessionId: string) => ConversationManager | null,
56
+ ): ConversationRewindPort {
57
+ const snapshots = new Map<string, SnapshotPair>();
58
+
59
+ function keepFor(anchor: RewindAnchor): { conv: ConversationManager | null; keep: number; total: number } {
60
+ const conv = resolveConversation(anchor.sessionId);
61
+ if (!conv) return { conv: null, keep: 0, total: 0 };
62
+ const total = conv.getMessageCount();
63
+ const rec = anchor.turnId ? resolveTurnAnchor(anchor.sessionId, anchor.turnId) : null;
64
+ const keep = rec ? Math.min(rec.messageCount, total) : total;
65
+ return { conv, keep, total };
66
+ }
67
+
68
+ function restore(snapshot: SnapshotPair | undefined, which: 'before' | 'after'): boolean {
69
+ if (!snapshot) return false;
70
+ snapshot.conv.fromJSON(snapshot[which]);
71
+ snapshot.conv.rebuildHistory();
72
+ return true;
73
+ }
74
+
75
+ return {
76
+ async preview(anchor: RewindAnchor): Promise<RewindConversationPreview> {
77
+ const { keep, total } = keepFor(anchor);
78
+ return { messagesToDrop: Math.max(0, total - keep), messagesRemaining: keep };
79
+ },
80
+
81
+ async rewind(anchor: RewindAnchor): Promise<RewindConversationOutcome> {
82
+ const { conv, keep, total } = keepFor(anchor);
83
+ const undoSnapshotId = `rwc_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
84
+ if (!conv) return { droppedMessages: 0, undoSnapshotId };
85
+ const before = conv.toJSON() as ConversationJson;
86
+ conv.removeMessagesAfter(keep);
87
+ conv.rebuildHistory();
88
+ const after = conv.toJSON() as ConversationJson;
89
+ snapshots.set(undoSnapshotId, { conv, before, after });
90
+ return { droppedMessages: Math.max(0, total - keep), undoSnapshotId };
91
+ },
92
+
93
+ restoreBefore(undoSnapshotId: string): boolean {
94
+ return restore(snapshots.get(undoSnapshotId), 'before');
95
+ },
96
+
97
+ restoreAfter(undoSnapshotId: string): boolean {
98
+ return restore(snapshots.get(undoSnapshotId), 'after');
99
+ },
100
+ };
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Live per-session conversation registry — the daemon-hosted mutable store the
105
+ // composed daemon's rewind.plan/apply verbs resolve conversations from. The TUI
106
+ // registers its active session's ConversationManager at bootstrap; a session
107
+ // with no registration reports conversation rewind as "nothing to drop".
108
+ // ---------------------------------------------------------------------------
109
+
110
+ const liveConversations = new Map<string, ConversationManager>();
111
+
112
+ /** Register a session's live conversation so the daemon rewind verbs can serve it. */
113
+ export function registerSessionConversation(sessionId: string, conversation: ConversationManager): void {
114
+ if (sessionId) liveConversations.set(sessionId, conversation);
115
+ }
116
+
117
+ /** Drop a session's conversation registration. */
118
+ export function unregisterSessionConversation(sessionId: string): void {
119
+ liveConversations.delete(sessionId);
120
+ }
121
+
122
+ /**
123
+ * The conversation rewind port the composed daemon threads into
124
+ * registerGatewayVerbGroups — it resolves each anchor's live conversation from
125
+ * the registry above, so the daemon's own rewind verbs serve conversation scope
126
+ * live in this process.
127
+ */
128
+ export function createSessionConversationRewindPort(): ConversationRewindPort {
129
+ return createConversationRewindPort((sessionId) => liveConversations.get(sessionId) ?? null);
130
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * fleet-needs-input-push.ts — wires the fleet "needs-input" browser-push fan-out
3
+ * that the SDK's registerGatewayVerbGroups (reached through the terminal-shell's
4
+ * attachWsOnlyGatewayVerbHandlers wrapper) gates on two deps: `runtimeBus` and
5
+ * `sessionPresence` (see push/service.ts's attachFleetNeedsInputSource). When a
6
+ * fleet node blocks on the operator, it pushes an "Input needed" notification
7
+ * carrying the session/node deep link to every registered device — suppressed
8
+ * while an operator surface is already attached to that node's session.
9
+ *
10
+ * TWO seams have to be live for a real push, not just descriptor presence:
11
+ * 1. Fleet lifecycle deltas (FLEET_NODE_BLOCKED_ON_USER etc.) have to reach the
12
+ * runtime bus's 'fleet' domain in the first place. That is
13
+ * attachFleetEmitBridge, diffing the process registry's own coalesced
14
+ * snapshot tick into per-node lifecycle events. The terminal-shell package's
15
+ * createArchivableFleetRegistry only builds the registry — it does not
16
+ * attach the bridge — so the composition root that owns both the registry
17
+ * and the bus (this file) is the one place responsible for it. The SDK's
18
+ * own daemon composition (platform/runtime/services.ts) attaches this same
19
+ * bridge right after building its process registry.
20
+ * 2. registerGatewayVerbGroups has to be handed `runtimeBus` (to subscribe onto
21
+ * that domain) and `sessionPresence` (to suppress a push when an operator is
22
+ * already looking at the session).
23
+ *
24
+ * sessionPresence mirrors the SDK's own daemon composition: a session counts as
25
+ * attended when any participant heartbeated within the freshness window below.
26
+ * Reimplemented here on the broker's public getSession()/SharedSessionRecord
27
+ * shape rather than imported, because the SDK does not expose its own
28
+ * hasFreshSurfaceParticipant helper as public API.
29
+ */
30
+ import { attachFleetEmitBridge } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
31
+ import type { ArchivableProcessRegistry } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
32
+ import type { SharedSessionBroker } from '@pellux/goodvibes-sdk/platform/control-plane';
33
+ import type { RuntimeEventBus } from '@/runtime/index.ts';
34
+
35
+ /**
36
+ * How recently a participant must have been seen to count as actively
37
+ * attached. Mirrors the SDK's own (private) SURFACE_ROUTE_FRESHNESS_MS.
38
+ */
39
+ const SURFACE_ATTACHED_FRESHNESS_MS = 2 * 60 * 1000;
40
+
41
+ export interface FleetNeedsInputPushDeps {
42
+ readonly runtimeBus: RuntimeEventBus;
43
+ readonly sessionPresence: { isAttached(sessionId: string): boolean };
44
+ }
45
+
46
+ /**
47
+ * Attach the fleet emit-bridge (registry snapshot deltas -> runtime bus `fleet`
48
+ * domain) and build the presence lookup, returning both as the deps
49
+ * `attachWsOnlyGatewayVerbHandlers` spreads into its call so the needs-input
50
+ * push source registers for real instead of gracefully degrading to absent.
51
+ */
52
+ export function wireFleetNeedsInputPush(input: {
53
+ readonly registry: ArchivableProcessRegistry;
54
+ readonly runtimeBus: RuntimeEventBus;
55
+ readonly sessionBroker: Pick<SharedSessionBroker, 'getSession'>;
56
+ }): FleetNeedsInputPushDeps {
57
+ attachFleetEmitBridge({ registry: input.registry, bus: input.runtimeBus });
58
+ const sessionBroker = input.sessionBroker;
59
+ return {
60
+ runtimeBus: input.runtimeBus,
61
+ sessionPresence: {
62
+ isAttached: (sessionId: string) => {
63
+ const session = sessionBroker.getSession(sessionId);
64
+ if (!session) return false;
65
+ const now = Date.now();
66
+ return session.participants.some((participant) => now - participant.lastSeenAt < SURFACE_ATTACHED_FRESHNESS_MS);
67
+ },
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * install-self-check-startup.ts — the boot-time surfacing of the install
3
+ * self-check (install-self-check.ts). Called once from the runtime bootstrap,
4
+ * it wires the real host inputs (process exec path, the packaged root, the
5
+ * real daemon resolution, and existsSync) into the pure evaluator and prints
6
+ * any findings through the same SystemMessageRouter the rest of startup uses.
7
+ *
8
+ * It is intentionally cheap (existsSync-level, no network) and non-fatal:
9
+ * every failure is swallowed so a self-check can never block or crash boot.
10
+ * Each finding is printed as one high-priority system message ending in the
11
+ * exact repair command for the detected install kind.
12
+ *
13
+ * This module lives in the runtime layer and imports the daemon resolver from
14
+ * the cli layer; nothing in cli imports it back, so no import cycle forms.
15
+ */
16
+
17
+ import { existsSync } from 'node:fs';
18
+ import { getGoodVibesPackageRoot, resolveGoodVibesDaemonExecutable } from '../cli/service-posture.ts';
19
+ import { runInstallSelfCheck } from './install-self-check.ts';
20
+ import type { SystemMessageRouter } from '../core/system-message-router.ts';
21
+
22
+ export function announceInstallSelfCheck(router: SystemMessageRouter): void {
23
+ try {
24
+ const findings = runInstallSelfCheck({
25
+ execPath: process.execPath,
26
+ packageRoot: getGoodVibesPackageRoot(),
27
+ daemon: resolveGoodVibesDaemonExecutable(),
28
+ fileExists: existsSync,
29
+ });
30
+ for (const finding of findings) {
31
+ router.high(`[Install] ${finding.summary} ${finding.detail} Repair: ${finding.repairCommand}`);
32
+ }
33
+ } catch {
34
+ // Best-effort — an install self-check must never block or crash boot.
35
+ }
36
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * install-self-check.ts — cheap, non-fatal detection of an incomplete
3
+ * install, run at TUI startup and re-surfaced by `goodvibes doctor`.
4
+ *
5
+ * A packaged install (the standalone `install.sh` binary, or the
6
+ * `bun add -g` vendored package) is only usable if its release binaries are
7
+ * actually present: the app binary the user is running, plus the separate
8
+ * background daemon binary. Two things can go wrong after a partial or
9
+ * interrupted install:
10
+ * 1. the vendored release binaries were never downloaded into <root>/vendor
11
+ * (scripts/postinstall.js downloads them for packaged installs), and
12
+ * 2. the daemon launch path resolves to nothing runnable, so any
13
+ * daemon/control-plane/listener/web surface silently fails to start.
14
+ *
15
+ * This module decides those two facts from injected inputs only — an
16
+ * install kind, a package root, the resolved daemon executable, and a
17
+ * `fileExists` predicate — so it is pure and testable with fake paths and
18
+ * never touches the network. The production wiring (real existsSync + the
19
+ * real daemon resolution) lives in install-self-check-startup.ts and in the
20
+ * CLI status/doctor entrypoint; this file only makes the judgement.
21
+ *
22
+ * A `source` checkout run via `bun src/main.ts` is NOT an incomplete
23
+ * install — there is no vendor directory to populate and the daemon runs
24
+ * from source — so it is never flagged.
25
+ *
26
+ * Install-kind detection and the per-kind repair one-liner are reused from
27
+ * update-check.ts rather than re-derived here.
28
+ */
29
+
30
+ import { join } from 'node:path';
31
+ import { detectInstallKind, fallbackUpdateCommand, type InstallKind } from './update-check.ts';
32
+ import { resolveArtifactNames } from './release-artifacts.ts';
33
+
34
+ /**
35
+ * The subset of a resolved daemon executable this check needs. Structurally
36
+ * matches the CLI's DaemonExecutableResolution without importing it from the
37
+ * cli layer (that would create an import cycle: cli imports this pure module,
38
+ * so this module must not import back into cli).
39
+ */
40
+ export interface DaemonPathResolution {
41
+ readonly command: string;
42
+ /** How the path was resolved. 'fallback' means no runnable file was found — a bare PATH command. */
43
+ readonly source: string;
44
+ readonly absolute: boolean;
45
+ }
46
+
47
+ export type InstallSelfCheckFindingId = 'missing-vendor-binaries' | 'broken-daemon-path';
48
+
49
+ export interface InstallSelfCheckFinding {
50
+ readonly id: InstallSelfCheckFindingId;
51
+ readonly summary: string;
52
+ readonly detail: string;
53
+ /** The exact command to run to repair this install kind. */
54
+ readonly repairCommand: string;
55
+ }
56
+
57
+ export interface InstallSelfCheckInput {
58
+ readonly installKind: InstallKind;
59
+ readonly packageRoot: string;
60
+ readonly platform: string;
61
+ readonly arch: string;
62
+ readonly daemon: DaemonPathResolution;
63
+ readonly fileExists: (path: string) => boolean;
64
+ }
65
+
66
+ /**
67
+ * The exact repair command per install kind. A standalone `binary` install is
68
+ * repaired by re-running the installer (which places both binaries); the other
69
+ * non-binary kinds reuse update-check.ts's fallbackUpdateCommand verbatim.
70
+ */
71
+ export function repairCommandForInstallKind(kind: InstallKind): string {
72
+ if (kind === 'binary') return 'curl -fsSL https://goodvibes.sh/install.sh | sh';
73
+ return fallbackUpdateCommand(kind);
74
+ }
75
+
76
+ /**
77
+ * Decide, from injected inputs only, whether this install is missing pieces.
78
+ * Returns zero findings for a source checkout (complete by definition) and for
79
+ * a healthy packaged install.
80
+ */
81
+ export function evaluateInstallSelfCheck(input: InstallSelfCheckInput): InstallSelfCheckFinding[] {
82
+ if (input.installKind === 'source') return [];
83
+
84
+ const findings: InstallSelfCheckFinding[] = [];
85
+ const repairCommand = repairCommandForInstallKind(input.installKind);
86
+ const artifacts = resolveArtifactNames(input.platform, input.arch);
87
+
88
+ // (1) Missing vendored binaries. Only the vendored package install ships a
89
+ // <root>/vendor directory (postinstall.js downloads app + daemon there); a
90
+ // standalone compiled binary has none, so this check applies only to the
91
+ // bun-global-package kind.
92
+ if (input.installKind === 'bun-global-package' && artifacts) {
93
+ const vendorDir = join(input.packageRoot, 'vendor');
94
+ const missing = [artifacts.app, artifacts.daemon].filter(
95
+ (name) => !input.fileExists(join(vendorDir, name)),
96
+ );
97
+ if (missing.length > 0) {
98
+ findings.push({
99
+ id: 'missing-vendor-binaries',
100
+ summary: 'Vendored release binaries are missing from this install.',
101
+ detail: `${missing.join(', ')} not found under ${vendorDir}.`,
102
+ repairCommand,
103
+ });
104
+ }
105
+ }
106
+
107
+ // (2) Broken daemon path. The daemon runs as a separate binary; if the
108
+ // resolver fell back to a bare PATH command (nothing runnable was found on
109
+ // the packaged search paths) or the resolved absolute path does not exist,
110
+ // any daemon/control-plane/listener/web surface will fail to start.
111
+ const daemonMissing = input.daemon.source === 'fallback'
112
+ || (input.daemon.absolute && !input.fileExists(input.daemon.command));
113
+ if (daemonMissing) {
114
+ findings.push({
115
+ id: 'broken-daemon-path',
116
+ summary: 'The background daemon binary could not be located.',
117
+ detail: input.daemon.source === 'fallback'
118
+ ? `No daemon executable was found on the packaged search paths; the launcher falls back to a bare "${input.daemon.command}" on PATH.`
119
+ : `The resolved daemon path ${input.daemon.command} does not exist.`,
120
+ repairCommand,
121
+ });
122
+ }
123
+
124
+ return findings;
125
+ }
126
+
127
+ /**
128
+ * Convenience wrapper that detects the install kind from an exec path (reusing
129
+ * update-check.ts) and then evaluates. Platform/arch default to the running
130
+ * host but are injectable for tests. The daemon resolution and fileExists are
131
+ * always injected so this stays free of process/filesystem coupling of its own.
132
+ */
133
+ export function runInstallSelfCheck(input: {
134
+ readonly execPath: string;
135
+ readonly packageRoot: string;
136
+ readonly daemon: DaemonPathResolution;
137
+ readonly fileExists: (path: string) => boolean;
138
+ readonly platform?: string;
139
+ readonly arch?: string;
140
+ }): InstallSelfCheckFinding[] {
141
+ return evaluateInstallSelfCheck({
142
+ installKind: detectInstallKind(input.execPath),
143
+ packageRoot: input.packageRoot,
144
+ platform: input.platform ?? process.platform,
145
+ arch: input.arch ?? process.arch,
146
+ daemon: input.daemon,
147
+ fileExists: input.fileExists,
148
+ });
149
+ }