@pellux/goodvibes-tui 1.16.1 → 1.17.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 (219) hide show
  1. package/CHANGELOG.md +89 -9
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1091 -271
  4. package/package.json +7 -7
  5. package/src/cli/completions/generate.ts +5 -4
  6. package/src/cli/config-overrides.ts +51 -11
  7. package/src/cli/doctor.ts +4 -5
  8. package/src/cli/entrypoint.ts +13 -8
  9. package/src/cli/help.ts +7 -6
  10. package/src/cli/launch-auto-update.ts +214 -0
  11. package/src/cli/parser.ts +5 -0
  12. package/src/cli/status.ts +13 -0
  13. package/src/cli/surface-command.ts +5 -5
  14. package/src/cli/tui-startup.ts +60 -97
  15. package/src/cli/types.ts +2 -0
  16. package/src/config/index.ts +1 -1
  17. package/src/config/tui-extension-settings.ts +33 -0
  18. package/src/core/composer-state.ts +1 -1
  19. package/src/core/conversation-line-cache.ts +3 -3
  20. package/src/core/conversation-rendering.ts +4 -4
  21. package/src/core/conversation.ts +2 -2
  22. package/src/core/long-task-notifier.ts +7 -7
  23. package/src/core/stream-event-wiring.ts +8 -6
  24. package/src/core/system-message-noise.ts +1 -1
  25. package/src/core/system-message-router.ts +6 -6
  26. package/src/core/turn-cancellation.ts +1 -1
  27. package/src/core/turn-event-wiring.ts +8 -8
  28. package/src/daemon/cli.ts +20 -5
  29. package/src/daemon/lifecycle.ts +41 -0
  30. package/src/daemon/service-commands.ts +5 -5
  31. package/src/export/cost-utils.ts +128 -6
  32. package/src/input/autocomplete.ts +2 -2
  33. package/src/input/command-registry.ts +34 -9
  34. package/src/input/commands/ci-runtime.ts +5 -1
  35. package/src/input/commands/codebase-runtime.ts +6 -6
  36. package/src/input/commands/cost-runtime.ts +1 -1
  37. package/src/input/commands/health-runtime.ts +2 -2
  38. package/src/input/commands/intelligence-runtime.ts +1 -1
  39. package/src/input/commands/local-auth-runtime.ts +1 -1
  40. package/src/input/commands/local-provider-runtime.ts +49 -10
  41. package/src/input/commands/local-runtime.ts +53 -5
  42. package/src/input/commands/memory.ts +2 -2
  43. package/src/input/commands/operator-panel-runtime.ts +5 -5
  44. package/src/input/commands/permissions-provenance.ts +1 -1
  45. package/src/input/commands/permissions-runtime.ts +99 -8
  46. package/src/input/commands/planning-runtime.ts +3 -3
  47. package/src/input/commands/platform-sandbox-runtime.ts +1 -1
  48. package/src/input/commands/provider-accounts-runtime.ts +71 -6
  49. package/src/input/commands/provider.ts +7 -5
  50. package/src/input/commands/recall-files-git.ts +13 -51
  51. package/src/input/commands/recall-files.ts +8 -11
  52. package/src/input/commands/recall-review.ts +2 -2
  53. package/src/input/commands/relay-runtime.ts +1 -1
  54. package/src/input/commands/remote-runtime.ts +1 -1
  55. package/src/input/commands/runtime-services.ts +4 -4
  56. package/src/input/commands/schedule-runtime.ts +14 -7
  57. package/src/input/commands/services-runtime.ts +1 -1
  58. package/src/input/commands/session-workflow.ts +2 -2
  59. package/src/input/commands/settings-sync-runtime.ts +2 -2
  60. package/src/input/commands/share-runtime.ts +1 -1
  61. package/src/input/commands/shell-core.ts +46 -68
  62. package/src/input/commands/subscription-runtime.ts +1 -1
  63. package/src/input/commands/update-runtime.ts +153 -96
  64. package/src/input/commands/work-plan-runtime.ts +1 -1
  65. package/src/input/commands/workstream-runtime.ts +2 -2
  66. package/src/input/commands.ts +0 -2
  67. package/src/input/config-modal-types.ts +3 -3
  68. package/src/input/config-modal.ts +10 -11
  69. package/src/input/feature-unit-layout.ts +93 -82
  70. package/src/input/feed-context-factory.ts +2 -2
  71. package/src/input/handler-command-route.ts +2 -2
  72. package/src/input/handler-content-actions.ts +1 -1
  73. package/src/input/handler-feed-routes.ts +7 -7
  74. package/src/input/handler-feed.ts +4 -4
  75. package/src/input/handler-interactions.ts +3 -2
  76. package/src/input/handler-modal-routes.ts +12 -4
  77. package/src/input/handler-modal-token-routes.ts +1 -1
  78. package/src/input/handler-onboarding-daemon-adopt.ts +2 -2
  79. package/src/input/handler-onboarding.ts +3 -2
  80. package/src/input/handler-picker-routes.ts +26 -13
  81. package/src/input/handler-shortcuts.ts +6 -6
  82. package/src/input/handler.ts +2 -2
  83. package/src/input/keybindings.ts +2 -2
  84. package/src/input/onboarding/onboarding-feature-units.ts +34 -27
  85. package/src/input/onboarding/onboarding-provider-key-fields.ts +112 -0
  86. package/src/input/onboarding/onboarding-wizard-apply.ts +22 -21
  87. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +17 -0
  88. package/src/input/onboarding/onboarding-wizard-network-adopt.ts +2 -2
  89. package/src/input/onboarding/onboarding-wizard-steps.ts +30 -42
  90. package/src/input/panel-integration-actions.ts +2 -2
  91. package/src/input/panel-mouse-geometry.ts +1 -1
  92. package/src/input/panel-paste-flood-guard.ts +1 -1
  93. package/src/input/provider-key-intake.ts +167 -0
  94. package/src/input/sandbox-exec-config.ts +2 -2
  95. package/src/input/selection-modal.ts +1 -1
  96. package/src/input/settings-modal-activation.ts +5 -0
  97. package/src/input/settings-modal-adjustment.ts +4 -31
  98. package/src/input/settings-modal-data.ts +22 -21
  99. package/src/input/settings-modal-mutations.ts +12 -68
  100. package/src/input/settings-modal-types.ts +23 -16
  101. package/src/input/settings-modal.ts +74 -55
  102. package/src/main.ts +48 -48
  103. package/src/panels/agent-inspector-shared.ts +4 -4
  104. package/src/panels/base-panel.ts +2 -2
  105. package/src/panels/builtin/agent.ts +4 -4
  106. package/src/panels/builtin/development.ts +4 -4
  107. package/src/panels/builtin/knowledge.ts +1 -1
  108. package/src/panels/builtin/operations.ts +29 -10
  109. package/src/panels/builtin/session.ts +5 -5
  110. package/src/panels/builtin/shared.ts +4 -4
  111. package/src/panels/builtin-modals.ts +10 -10
  112. package/src/panels/builtin-panels.ts +1 -1
  113. package/src/panels/cost-tracker-panel.ts +124 -7
  114. package/src/panels/diff-panel.ts +5 -5
  115. package/src/panels/eval-registry.ts +2 -2
  116. package/src/panels/fleet-deep-link.ts +1 -1
  117. package/src/panels/fleet-panel-format.ts +44 -8
  118. package/src/panels/fleet-panel.ts +16 -16
  119. package/src/panels/fleet-read-model.ts +52 -13
  120. package/src/panels/fleet-steer.ts +2 -2
  121. package/src/panels/fleet-stop.ts +3 -3
  122. package/src/panels/fleet-tabs.ts +13 -14
  123. package/src/panels/fleet-transcript.ts +5 -5
  124. package/src/panels/git-panel.ts +2 -2
  125. package/src/panels/index.ts +4 -1
  126. package/src/panels/local-auth-panel.ts +1 -1
  127. package/src/panels/modals/hooks-modal.ts +1 -1
  128. package/src/panels/modals/keybindings-modal.ts +2 -2
  129. package/src/panels/modals/knowledge-modal.ts +1 -1
  130. package/src/panels/modals/marketplace-modal.ts +4 -4
  131. package/src/panels/modals/memory-modal.ts +4 -4
  132. package/src/panels/modals/modal-surface-helpers.ts +1 -1
  133. package/src/panels/modals/modal-theme.ts +1 -1
  134. package/src/panels/modals/pairing-modal.ts +2 -2
  135. package/src/panels/modals/planning-modal.ts +2 -2
  136. package/src/panels/modals/plugins-modal.ts +1 -1
  137. package/src/panels/modals/policy-modal.ts +1 -1
  138. package/src/panels/modals/security-modal.ts +1 -1
  139. package/src/panels/modals/skills-modal.ts +1 -1
  140. package/src/panels/modals/work-plan-modal.ts +3 -3
  141. package/src/panels/notifications-feed.ts +127 -0
  142. package/src/panels/notifications-panel.ts +160 -0
  143. package/src/panels/panel-manager.ts +9 -9
  144. package/src/panels/plugins-panel.ts +4 -4
  145. package/src/panels/project-planning-answer-actions.ts +1 -1
  146. package/src/panels/provider-health-routes.ts +2 -2
  147. package/src/panels/skills-panel.ts +4 -5
  148. package/src/panels/token-budget-panel.ts +11 -3
  149. package/src/panels/types.ts +5 -5
  150. package/src/permissions/broker-approval-card.ts +201 -0
  151. package/src/permissions/hunk-selection.ts +28 -4
  152. package/src/permissions/prompt.ts +244 -29
  153. package/src/renderer/autocomplete-overlay.ts +107 -54
  154. package/src/renderer/code-block.ts +2 -2
  155. package/src/renderer/compaction-history-modal.ts +1 -1
  156. package/src/renderer/compaction-preview.ts +1 -1
  157. package/src/renderer/compaction-quality.ts +1 -1
  158. package/src/renderer/compositor.ts +2 -2
  159. package/src/renderer/config-modal.ts +1 -1
  160. package/src/renderer/conversation-overlays.ts +1 -1
  161. package/src/renderer/footer-tips.ts +3 -3
  162. package/src/renderer/hint-grammar.ts +1 -1
  163. package/src/renderer/modal-factory.ts +1 -1
  164. package/src/renderer/model-picker-overlay.ts +1 -1
  165. package/src/renderer/model-workspace.ts +7 -2
  166. package/src/renderer/panel-composite.ts +2 -2
  167. package/src/renderer/panel-workspace-bar.ts +1 -1
  168. package/src/renderer/process-indicator.ts +1 -1
  169. package/src/renderer/selection-modal-overlay.ts +1 -1
  170. package/src/renderer/session-picker-modal.ts +2 -2
  171. package/src/renderer/settings-modal-helpers.ts +14 -5
  172. package/src/renderer/settings-modal.ts +127 -39
  173. package/src/renderer/shell-surface.ts +3 -0
  174. package/src/renderer/terminal-bg-probe.ts +1 -1
  175. package/src/renderer/theme.ts +3 -3
  176. package/src/renderer/tool-call.ts +1 -1
  177. package/src/renderer/tool-result-summary.ts +1 -1
  178. package/src/renderer/turn-injection.ts +7 -6
  179. package/src/renderer/ui-factory.ts +6 -1
  180. package/src/runtime/bootstrap-command-context.ts +4 -1
  181. package/src/runtime/bootstrap-command-parts.ts +6 -4
  182. package/src/runtime/bootstrap-core.ts +35 -35
  183. package/src/runtime/bootstrap-hook-bridge.ts +1 -1
  184. package/src/runtime/bootstrap-shell.ts +6 -5
  185. package/src/runtime/bootstrap.ts +34 -32
  186. package/src/runtime/channel-composition.ts +46 -0
  187. package/src/runtime/code-index-services.ts +3 -4
  188. package/src/runtime/daemon-attach-notices.ts +120 -0
  189. package/src/runtime/durability-services.ts +55 -0
  190. package/src/runtime/embedded-service-factories.ts +40 -0
  191. package/src/runtime/feature-settings.ts +147 -0
  192. package/src/runtime/index.ts +4 -21
  193. package/src/runtime/legacy-daemon-migration.ts +29 -19
  194. package/src/runtime/memory-fold.ts +1 -1
  195. package/src/runtime/notification-dispatch.ts +110 -0
  196. package/src/runtime/onboarding/apply.ts +3 -29
  197. package/src/runtime/onboarding/fast-path.ts +39 -6
  198. package/src/runtime/onboarding/provider-key-capture.ts +126 -0
  199. package/src/runtime/onboarding/snapshot.ts +0 -1
  200. package/src/runtime/onboarding/types.ts +9 -3
  201. package/src/runtime/provider-fallback.ts +55 -0
  202. package/src/runtime/provider-optimizer-wiring.ts +55 -0
  203. package/src/runtime/release-artifacts.ts +19 -103
  204. package/src/runtime/resume-notice.ts +1 -1
  205. package/src/runtime/services.ts +81 -82
  206. package/src/runtime/session-continuity-hints.ts +38 -0
  207. package/src/runtime/surface-feature-flags.ts +40 -9
  208. package/src/runtime/terminal-output-guard.ts +2 -2
  209. package/src/runtime/trust/workspace-registration.ts +10 -2
  210. package/src/runtime/trust/workspace-trust.ts +58 -31
  211. package/src/runtime/ui-services.ts +3 -1
  212. package/src/runtime/update-check.ts +15 -69
  213. package/src/runtime/workstream-services.ts +2 -2
  214. package/src/shell/blocking-input.ts +102 -6
  215. package/src/shell/ui-openers.ts +38 -6
  216. package/src/utils/format-duration.ts +1 -1
  217. package/src/verification/verification-ledger.ts +21 -4
  218. package/src/version.ts +1 -1
  219. package/src/input/commands/flags-runtime.ts +0 -328
@@ -2,8 +2,8 @@ import { join } from 'node:path';
2
2
  import { FocusTracker } from '../core/focus-tracker.ts';
3
3
  import { ConfigManager, ServiceRegistry, SubscriptionManager, ToolLLM } from '@pellux/goodvibes-sdk/platform/config';
4
4
  import { SecretsManager } from '../config/secrets.ts';
5
- import { AutomationDeliveryManager, AutomationManager, AutomationRouteStore } from '@pellux/goodvibes-sdk/platform/automation';
6
- import { ChannelDeliveryRouter, ChannelPluginRegistry, ChannelPolicyManager, RouteBindingManager, SurfaceRegistry } from '@pellux/goodvibes-sdk/platform/channels';
5
+ import { AutomationDeliveryManager, AutomationManager } from '@pellux/goodvibes-sdk/platform/automation';
6
+ import { ChannelDeliveryRouter, ChannelPolicyManager, type ChannelPluginRegistry, type RouteBindingManager, type SurfaceRegistry } from '@pellux/goodvibes-sdk/platform/channels';
7
7
  import { ApprovalBroker, GatewayMethodCatalog, SharedSessionBroker } from '@pellux/goodvibes-sdk/platform/control-plane';
8
8
  import { StepUpService } from '@pellux/goodvibes-sdk/daemon';
9
9
  import { attachWsOnlyGatewayVerbHandlers, createArchivableFleetRegistry } from '@pellux/goodvibes-terminal-shell';
@@ -26,6 +26,12 @@ import { MultimodalService } from '@pellux/goodvibes-sdk/platform/multimodal';
26
26
  import { AgentMessageBus, AgentOrchestrator, ArchetypeLoader, WrfcController } from '@pellux/goodvibes-sdk/platform/agents';
27
27
  import { AgentManager, ContextAccountingHolder, OverflowHandler, ProcessManager, createWorkflowServices, type WorkflowServices } from '@pellux/goodvibes-sdk/platform/tools';
28
28
  import { FileStateCache, FileUndoManager, MemoryEmbeddingProviderRegistry, MemoryRegistry, MemoryStore, ModeManager, ProjectIndex, resolveCanonicalMemoryDbPath, type CodeIndexStore, type CodeIndexReindexScheduler } from '@pellux/goodvibes-sdk/platform/state';
29
+ import type { StoreSnapshotScheduler } from '@pellux/goodvibes-sdk/platform/state/store-snapshots';
30
+ import type { UserPermissionRuleStore } from '@pellux/goodvibes-sdk/platform/permissions';
31
+ import { buildExecPromptAnswerHandler } from '@pellux/goodvibes-sdk/platform/runtime/permissions/exec-prompt-wiring';
32
+ import { buildLocalhostFetchApproval } from '@pellux/goodvibes-sdk/platform/runtime/permissions/localhost-fetch-approval';
33
+ import { createNotificationDispatcher, wireRuntimeNotificationBridge, type NotificationDispatcher } from './notification-dispatch.ts';
34
+ import { createDurabilityServices } from './durability-services.ts';
29
35
  import { MemorySpineClient, createLocalMemoryAccess } from '@pellux/goodvibes-sdk/platform/runtime/memory-spine';
30
36
  import { WorkspaceCheckpointManager } from '@pellux/goodvibes-sdk/platform/workspace';
31
37
  import { createWorkspaceCheckpointing } from './workspace-checkpointing.ts';
@@ -49,20 +55,18 @@ import { SessionManager, CrossSessionTaskRegistry, SessionChangeTracker } from '
49
55
  import { ApiTokenAuditor, UserAuthManager } from '@pellux/goodvibes-sdk/platform/security';
50
56
  import { WebhookNotifier } from '@pellux/goodvibes-sdk/platform/integrations';
51
57
  import { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
52
- import { BenchmarkStore, CacheHitTracker, FavoritesStore, inferFallbackContextWindow, type ModelDefinition, ModelLimitsService, ProviderCapabilityRegistry, ProviderOptimizer, ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
58
+ import { BenchmarkStore, CacheHitTracker, computeUsageCostUsd, FavoritesStore, ModelLimitsService, ProviderCapabilityRegistry, ProviderOptimizer, ProviderRegistry } from '@pellux/goodvibes-sdk/platform/providers';
53
59
  import { KeybindingsManager } from '../input/keybindings.ts';
54
60
  import { AdaptivePlanner, DeterministicReplayEngine, ExecutionPlanManager, SessionLineageTracker, SessionMemoryStore } from '@pellux/goodvibes-sdk/platform/core';
55
- import { isFeatureFlagEnabled } from './surface-feature-flags.ts';
61
+ import { deriveFeatureStates, bindFeatureSettingsBridge } from '@pellux/goodvibes-sdk/platform/runtime/state';
62
+ import { createChannelComposition } from './channel-composition.ts';
63
+ import { applyProviderOptimizerConfigMode, bindProviderOptimizerFeatureFlag } from './provider-optimizer-wiring.ts';
56
64
  import { type ArchivableProcessRegistry } from '@pellux/goodvibes-sdk/platform/runtime/fleet';
57
- import { calcSessionCost, isModelPriced } from '../export/cost-utils.ts';
58
65
  import { createWorkstreamServices, type OrchestrationEngine, type WorkstreamCommandService } from './workstream-services.ts';
59
66
  import { wireFleetNeedsInputPush } from './fleet-needs-input-push.ts';
60
67
  import { codeIndexDbPath, createCodeIndexServices, isCodeInjectionSettingEnabled } from './code-index-services.ts';
61
68
  import { WorkPlanStore } from '../work-plans/work-plan-store.ts';
62
- import {
63
- registerDaemonHandlers,
64
- type DaemonHandlerSurfaces,
65
- } from '../daemon/handlers/index.ts';
69
+ import { registerDaemonHandlers, type DaemonHandlerSurfaces } from '../daemon/handlers/index.ts';
66
70
  import type { HandlerContext, HandlerLogger } from '../daemon/handlers/context.ts';
67
71
  import { createDaemonCredentialStore } from '../daemon/handlers/credentials.ts';
68
72
  import { registerRouting } from '../daemon/handlers/routing/index.ts';
@@ -73,58 +77,10 @@ import { registerCalendar } from '../daemon/handlers/calendar/index.ts';
73
77
  import { registerEmailMethods } from '../daemon/handlers/email/index.ts';
74
78
  import { registerRemoteSurface } from '../daemon/handlers/remote/index.ts';
75
79
  import { WorkspaceTrustManager } from './trust/workspace-trust.ts';
80
+ import { ensureConfiguredModelIsRoutable } from './provider-fallback.ts';
76
81
 
77
82
  const REGULAR_KNOWLEDGE_DB_FILE = 'knowledge-wiki.sqlite';
78
83
  const HOME_GRAPH_KNOWLEDGE_DB_FILE = 'knowledge-home-graph.sqlite';
79
-
80
- function buildFallbackModelDefinition(provider: string, modelId: string): ModelDefinition {
81
- const providerLower = provider.toLowerCase();
82
- const isReasoningProvider = providerLower.includes('openai')
83
- || providerLower.includes('anthropic')
84
- || providerLower.includes('gemini')
85
- || providerLower.includes('google');
86
-
87
- return {
88
- id: modelId,
89
- provider,
90
- registryKey: `${provider}:${modelId}`,
91
- displayName: modelId,
92
- description: 'Configured model available before the model catalog cache has loaded.',
93
- capabilities: {
94
- toolCalling: true,
95
- codeEditing: true,
96
- reasoning: isReasoningProvider,
97
- multimodal: isReasoningProvider,
98
- },
99
- // Pre-catalog fallback uses the SDK's family-aware inference (SDK 0.35.0+),
100
- // matching the post-catalog window so the meter/compaction denominator agrees.
101
- contextWindow: inferFallbackContextWindow(provider, modelId),
102
- contextWindowProvenance: 'fallback',
103
- selectable: true,
104
- tier: 'standard',
105
- ...(isReasoningProvider ? { reasoningEffort: ['instant', 'low', 'medium', 'high'] } : {}),
106
- };
107
- }
108
-
109
- function ensureConfiguredModelIsRoutable(providerRegistry: ProviderRegistry, configManager: ConfigManager): void {
110
- const configuredModel = String(configManager.get('provider.model') ?? '').trim();
111
- if (!configuredModel.includes(':')) return;
112
- if (providerRegistry.listModels().some((model) => model.registryKey === configuredModel)) return;
113
-
114
- const [providerId, ...modelParts] = configuredModel.split(':');
115
- const modelId = modelParts.join(':').trim();
116
- if (!providerId || !modelId) return;
117
-
118
- const provider = providerRegistry.tryGet(providerId);
119
- if (!provider) return;
120
-
121
- providerRegistry.registerRuntimeProvider({
122
- provider,
123
- replace: true,
124
- models: [buildFallbackModelDefinition(providerId, modelId)],
125
- });
126
- }
127
-
128
84
  export interface RuntimeServicesOptions {
129
85
  readonly runtimeBus: RuntimeEventBus;
130
86
  readonly runtimeStore: RuntimeStore;
@@ -134,8 +90,6 @@ export interface RuntimeServicesOptions {
134
90
  readonly getConversationTitle?: () => string | undefined;
135
91
  readonly workingDir: string;
136
92
  readonly homeDirectory: string;
137
- /** Prior GoodVibes runtime state existed at startup — sampled before any write; seeds the trust gate's grandfathering. */
138
- readonly workspaceHadPriorState?: boolean;
139
93
  }
140
94
 
141
95
  export interface RuntimeServices {
@@ -155,6 +109,14 @@ export interface RuntimeServices {
155
109
  readonly channelDeliveryRouter: ChannelDeliveryRouter;
156
110
  readonly watcherRegistry: WatcherRegistry;
157
111
  readonly approvalBroker: ApprovalBroker;
112
+ /** Loopback-fetch approval that rides the approval broker; shared by the tool registry and orchestrator so every surface asks the same way. */
113
+ readonly localhostFetchApproval: ReturnType<typeof buildLocalhostFetchApproval>;
114
+ /** Terminal prompt-answer handler that rides the approval broker; shared by the tool registry and orchestrator so an interactive command's prompt gets an ask/card on every surface. */
115
+ readonly execPromptAnswerHandler: ReturnType<typeof buildExecPromptAnswerHandler>;
116
+ /** Routes curated runtime-domain events into the panel_only notification feed (the panel's live producer). */
117
+ readonly notificationDispatcher: NotificationDispatcher;
118
+ /** Durable user-origin permission rules (remembered approvals); permissions.rules.* surface. Mirrors the SDK composition. */
119
+ readonly userPermissionRuleStore: UserPermissionRuleStore;
158
120
  readonly sessionBroker: SharedSessionBroker;
159
121
  readonly deliveryManager: AutomationDeliveryManager;
160
122
  readonly automationManager: AutomationManager;
@@ -198,7 +160,7 @@ export interface RuntimeServices {
198
160
  readonly worktreeRegistry: WorktreeRegistry;
199
161
  readonly sandboxSessionRegistry: SandboxSessionRegistry;
200
162
  readonly webhookNotifier: WebhookNotifier;
201
- /** Terminal focus tracker (W2.3) — fed by input/handler-feed.ts, read by the alert notifiers in core/. */
163
+ /** Terminal focus tracker — fed by input/handler-feed.ts, read by the alert notifiers in core/. */
202
164
  readonly focusTracker: FocusTracker;
203
165
  readonly replayEngine: DeterministicReplayEngine;
204
166
  readonly providerOptimizer: ProviderOptimizer;
@@ -234,6 +196,8 @@ export interface RuntimeServices {
234
196
  /** The repo source-tree code index — see runtime/code-index-services.ts. */
235
197
  readonly codeIndexStore: CodeIndexStore;
236
198
  readonly codeIndexReindexScheduler: CodeIndexReindexScheduler; // tool-site reindex
199
+ /** Daily snapshots of every SQLite store this runtime writes, with bounded retention; unref'd timers (mirrors the SDK composition — hosts that tear down a runtime stop() it themselves). */
200
+ readonly storeSnapshotScheduler: StoreSnapshotScheduler;
237
201
  /** Unified live process registry (agents, WRFC chains, workflows, watchers, background processes) backing the Fleet panel; archive-aware — finished subtrees can be moved to the session archive view. */
238
202
  readonly processRegistry: ArchivableProcessRegistry;
239
203
  readonly modeManager: ModeManager;
@@ -253,26 +217,31 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
253
217
  workingDirectory,
254
218
  homeDirectory,
255
219
  });
256
- const workspaceTrustManager = new WorkspaceTrustManager({ shellPaths, hadPriorState: options.workspaceHadPriorState ?? false });
220
+ const workspaceTrustManager = new WorkspaceTrustManager({ shellPaths });
257
221
  const configManager = options.configManager;
258
222
  const featureFlags = options.featureFlags ?? createFeatureFlagManager();
223
+ if (options.featureFlags === undefined) {
224
+ // Owned manager: gate states derive from domain settings keys + live
225
+ // bridge (mirrors the SDK composition root; a passed manager is the caller's to wire).
226
+ featureFlags.loadFromConfig({ flags: deriveFeatureStates(configManager) });
227
+ bindFeatureSettingsBridge(configManager, featureFlags);
228
+ }
259
229
  const runtimeDispatch = createDomainDispatch(options.runtimeStore);
260
230
  const gatewayMethods = new GatewayMethodCatalog();
261
231
  const panelManager = new PanelManager();
262
- // W6.1 (the purge): MIGRATE-TO-MODAL surface + redirect registration moved to
232
+ // (the purge): MIGRATE-TO-MODAL surface + redirect registration moved to
263
233
  // registerBuiltinPanels (builtin-panels.ts), where the panels' resolved deps
264
234
  // are available for the surfaces to close over.
265
235
  const keybindingsManager = new KeybindingsManager({
266
236
  configPath: shellPaths.resolveUserPath('tui', 'keybindings.json'),
267
237
  });
268
- const routeBindings = new RouteBindingManager({
269
- store: new AutomationRouteStore({ configManager }),
238
+ // Channel/surface wiring: see channel-composition.ts (incl. the recorded surface-gating divergence note).
239
+ const { routeBindings, surfaceRegistry, channelPlugins } = createChannelComposition({
240
+ configManager,
270
241
  runtimeStore: options.runtimeStore,
271
242
  runtimeBus: options.runtimeBus,
243
+ featureFlags,
272
244
  });
273
- const surfaceRegistry = new SurfaceRegistry(configManager, options.runtimeStore);
274
- const channelPlugins = new ChannelPluginRegistry();
275
- surfaceRegistry.attachPluginRegistry(channelPlugins);
276
245
  const secretsManager = new SecretsManager({
277
246
  projectRoot: workingDirectory,
278
247
  globalHome: homeDirectory,
@@ -307,6 +276,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
307
276
  });
308
277
  ensureConfiguredModelIsRoutable(providerRegistry, configManager);
309
278
  providerRegistry.initCustomProviders();
279
+ // Kick off the background, TTL-respecting live model discovery sweep so
280
+ // provider model lists refresh from their own listing APIs — matching the
281
+ // SDK and agent compositions (this call was the one omitted here).
282
+ providerRegistry.initProviderModelDiscovery();
310
283
  const toolLLM = new ToolLLM({
311
284
  configManager,
312
285
  providerRegistry,
@@ -341,6 +314,9 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
341
314
  messageBus: agentMessageBus,
342
315
  executor: agentOrchestrator,
343
316
  configManager,
317
+ // The live registry lets a bare model id in a spawn() override resolve
318
+ // through the shared resolver instead of being rejected as unqualified.
319
+ providerRegistry,
344
320
  });
345
321
  const contextAccountingHolder = new ContextAccountingHolder();
346
322
  agentOrchestrator.setConversationSink({ // Conversation-snapshot bridge (mirrors the SDK's own createRuntimeServices)
@@ -418,6 +394,9 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
418
394
  runtimeStore: options.runtimeStore,
419
395
  runtimeBus: options.runtimeBus,
420
396
  deliveryManager,
397
+ // Same live registry: a bare model id on an automation job resolves through
398
+ // the shared resolver instead of a format-only rejection.
399
+ providerRegistry,
421
400
  spawnTask: (input) => {
422
401
  const record = agentManager.spawn({
423
402
  mode: 'spawn',
@@ -578,11 +557,9 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
578
557
  const webhookNotifier = new WebhookNotifier();
579
558
  const focusTracker = new FocusTracker();
580
559
  const replayEngine = new DeterministicReplayEngine(workingDirectory);
581
- const providerOptimizer = new ProviderOptimizer(
582
- providerRegistry,
583
- providerCapabilityRegistry,
584
- isFeatureFlagEnabled(configManager, 'provider-optimizer'),
585
- );
560
+ const providerOptimizer = new ProviderOptimizer(providerRegistry, providerCapabilityRegistry, false); // dark until its gate flips it (see provider-optimizer-wiring.ts)
561
+ bindProviderOptimizerFeatureFlag(featureFlags, providerOptimizer);
562
+ applyProviderOptimizerConfigMode(configManager, providerOptimizer);
586
563
  const sessionMemoryStore = new SessionMemoryStore();
587
564
  const sessionLineageTracker = new SessionLineageTracker();
588
565
  const sessionChangeTracker = new SessionChangeTracker();
@@ -611,6 +588,11 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
611
588
  // with MemoryStore above. Auto-build is config-gated (default off) — see
612
589
  // code-index-services.ts's header doc.
613
590
  const { codeIndexStore, codeIndexReindexScheduler } = createCodeIndexServices({ workingDirectory, configManager, memoryEmbeddingRegistry });
591
+ // Store snapshots + durable remembered-approval rules + the live credential
592
+ // chain, mirroring the SDK composition — see durability-services.ts.
593
+ const { storeSnapshotScheduler, userPermissionRuleStore } = createDurabilityServices({
594
+ configManager, secretsManager, providerRegistry, memoryDbPath, codeIndexDbPath: codeIndexDbPath(workingDirectory),
595
+ });
614
596
  const codeInjectionOrchestratorDeps = { codeIndex: codeIndexStore, isCodeInjectionSettingEnabled: () => isCodeInjectionSettingEnabled(configManager), codeIndexReindexScheduler }; // Code-injection seam (agent here; main via orchestrator-core-services.ts)
615
597
  // Shared, archive-aware fleet registry — see gateway-verbs.ts's factory doc.
616
598
  const processRegistry = createArchivableFleetRegistry({
@@ -626,12 +608,9 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
626
608
  messageBus: agentMessageBus, // Backs steer()/`steerable` (the Fleet steer composer builds on top)
627
609
  automationManager, // Folds /schedule AutomationJobs into the fleet as 'schedule' nodes
628
610
  runtimeBus: options.runtimeBus,
629
- // Honest pricing: never fabricate a cost for an unrecognized model.
630
- priceUsage: (model, usage) => {
631
- const modelId = model ?? 'unknown';
632
- if (!isModelPriced(modelId)) return null;
633
- return calcSessionCost(usage.inputTokens, usage.outputTokens, usage.cacheReadTokens, usage.cacheWriteTokens, modelId);
634
- },
611
+ // Honest-unpriced through the ONE pricing resolver (manual -> registration -> provider-served
612
+ // -> catalog -> unknown); unknown/subscription yields null, never $0. Mirrors the SDK composition.
613
+ priceUsage: (model, usage) => (model ? computeUsageCostUsd(providerRegistry.resolveModelPricing(model), usage) : null),
635
614
  });
636
615
  const modeManager = new ModeManager();
637
616
  const fileUndoManager = new FileUndoManager();
@@ -641,7 +620,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
641
620
  // family without it. principals.*/channels.profiles.*/ci.* register unconditionally; checkin.* only with
642
621
  // channelDeliveryRouter/providerRegistry/automationManager/sessionLister ALL present; fleet needs-input push gated by
643
622
  // runtimeBus/sessionPresence (fleet-needs-input-push.ts). conversationRewindPort serves conversation-scope rewind live.
644
- attachWsOnlyGatewayVerbHandlers(gatewayMethods, { processRegistry, workspaceCheckpointManager, conversationRewindPort: createSessionConversationRewindPort(), sessionBroker, secretsManager, stepUpService, approvalBroker, shellPaths, configManager, runtimeStore: options.runtimeStore, channelDeliveryRouter, providerRegistry, automationManager, sessionLister: sessionBroker, ...wireFleetNeedsInputPush({ registry: processRegistry, runtimeBus: options.runtimeBus, sessionBroker }) });
623
+ 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, ...wireFleetNeedsInputPush({ registry: processRegistry, runtimeBus: options.runtimeBus, sessionBroker }) });
645
624
  const integrationHelpers = new IntegrationHelperService({
646
625
  workingDirectory,
647
626
  homeDirectory,
@@ -662,8 +641,16 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
662
641
  subscriptionManager,
663
642
  secretsManager,
664
643
  });
644
+ // A loopback fetch that isn't allow-listed asks once through the approval
645
+ // broker; "allow for this project" persists and later fetches never ask. Built
646
+ // once and shared with the tool registry (bootstrap-core) so both ask alike.
647
+ const localhostFetchApproval = buildLocalhostFetchApproval({ requestApproval: (input) => approvalBroker.requestApproval(input), configManager });
648
+ // 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.
649
+ const execPromptAnswerHandler = buildExecPromptAnswerHandler({ requestApproval: (input) => approvalBroker.requestApproval(input) });
665
650
  agentOrchestrator.setDependencies({
666
651
  surfaceRoot: 'tui',
652
+ execPromptAnswerHandler,
653
+ localhostFetchApproval,
667
654
  fileCache,
668
655
  projectIndex,
669
656
  workingDirectory,
@@ -691,6 +678,12 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
691
678
  contextAccountingHolder,
692
679
  });
693
680
 
681
+ // Give the panel_only notification target a live producer: curated
682
+ // runtime-domain events route through the notification router into the shared
683
+ // panel feed (panel_only and burst-collapsed decisions land there).
684
+ const notificationDispatcher = createNotificationDispatcher(configManager);
685
+ wireRuntimeNotificationBridge(options.runtimeBus, notificationDispatcher);
686
+
694
687
  return {
695
688
  workingDirectory,
696
689
  homeDirectory,
@@ -709,6 +702,10 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
709
702
  channelDeliveryRouter,
710
703
  watcherRegistry,
711
704
  approvalBroker,
705
+ localhostFetchApproval,
706
+ execPromptAnswerHandler,
707
+ notificationDispatcher,
708
+ userPermissionRuleStore,
712
709
  sessionBroker,
713
710
  deliveryManager,
714
711
  automationManager,
@@ -784,6 +781,7 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
784
781
  workstreamCommands,
785
782
  codeIndexStore,
786
783
  codeIndexReindexScheduler,
784
+ storeSnapshotScheduler,
787
785
  processRegistry,
788
786
  modeManager,
789
787
  fileUndoManager,
@@ -791,8 +789,9 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
791
789
  integrationHelpers,
792
790
  async rerootStores(newWorkingDir: string): Promise<void> {
793
791
  // The memory store is the home-scoped canonical cross-surface store and
794
- // deliberately does NOT reroot per-project (that would re-silo memory, the E6
795
- // regression). Only working-tree-bound stores (code index, project index) reroot.
792
+ // deliberately does NOT reroot per-project (that would re-silo memory back
793
+ // into per-project stores). Only working-tree-bound stores (code index,
794
+ // project index) reroot.
796
795
  await codeIndexStore.reroot(newWorkingDir, codeIndexDbPath(newWorkingDir));
797
796
  await projectIndex.reroot(newWorkingDir);
798
797
  },
@@ -0,0 +1,38 @@
1
+ /**
2
+ * session-continuity-hints — the recovery snapshot's continuity summary.
3
+ *
4
+ * Extracted from main.ts (which sits at the architecture line cap) so the
5
+ * startup wiring there stays within budget. Builds the small counts/paths
6
+ * bundle written into the recovery file so a resumed session can describe what
7
+ * was in flight (pending approvals, active/blocked tasks, remotes, worktrees,
8
+ * open panels) without re-reading live state.
9
+ */
10
+ import type { UiReadModels } from './ui-read-models.ts';
11
+ import type { PanelManager } from '../panels/panel-manager.ts';
12
+
13
+ /**
14
+ * The continuity summary written into the recovery file. A loose serializable
15
+ * bag (Record) because it is JSON-persisted and read back by the recovery
16
+ * continuity-hints consumer, which types it structurally.
17
+ */
18
+ export function createSessionContinuityHintsBuilder(deps: {
19
+ readonly readModels: UiReadModels;
20
+ readonly panelManager: Pick<PanelManager, 'getAllOpen'>;
21
+ }): () => Record<string, unknown> {
22
+ return () => {
23
+ const sessionSnapshot = deps.readModels.session.getSnapshot();
24
+ const tasksSnapshot = deps.readModels.tasks.getSnapshot();
25
+ const remoteSnapshot = deps.readModels.remote.getSnapshot();
26
+ const worktreeSnapshot = deps.readModels.worktrees.getSnapshot();
27
+ return {
28
+ pendingApprovals: sessionSnapshot.pendingApproval ? 1 : 0,
29
+ activeTasks: tasksSnapshot.tasks.filter((task) => task.status === 'running' || task.status === 'queued').length,
30
+ blockedTasks: tasksSnapshot.tasks.filter((task) => task.status === 'blocked').length,
31
+ remoteContracts: remoteSnapshot.contracts.length,
32
+ remoteRunners: remoteSnapshot.contracts.slice(0, 4).map((contract) => contract.runnerId),
33
+ worktreeCount: worktreeSnapshot.records.length,
34
+ worktreePaths: worktreeSnapshot.records.slice(0, 3).map((record) => record.path),
35
+ openPanels: deps.panelManager.getAllOpen().map((panel) => panel.id),
36
+ };
37
+ };
38
+ }
@@ -1,5 +1,16 @@
1
- import type { ConfigKey, ConfigManager, PersistedFlagState } from '../config/index.ts';
1
+ /**
2
+ * surface-feature-flags — which platform capabilities a surface/server setup
3
+ * needs, and how to switch them on through their real domain settings keys.
4
+ *
5
+ * Capabilities are configured through first-class domain settings (see
6
+ * feature-settings.ts); enabling a surface writes keys like
7
+ * `controlPlane.gateway` or `surfaces.slack.enabled`, never a separate
8
+ * enablement namespace.
9
+ */
10
+
11
+ import type { ConfigManager } from '../config/index.ts';
2
12
  import { surfaceFeatureGateId } from '@/runtime/index.ts';
13
+ import { featureEnablementWrite, getFeatureSetting, isFeatureConfigEnabled } from './feature-settings.ts';
3
14
 
4
15
  export const CONTROL_PLANE_FEATURE_FLAG = 'control-plane-gateway';
5
16
  export const ROUTE_BINDING_FEATURE_FLAG = 'route-binding';
@@ -12,10 +23,15 @@ const CORE_CHANNEL_FEATURE_FLAGS = [
12
23
  DELIVERY_ENGINE_FEATURE_FLAG,
13
24
  ] as const;
14
25
 
15
- export type FeatureFlagConfigKey = 'featureFlags' | `featureFlags.${string}`;
16
-
17
26
  export function getSurfaceFeatureFlag(surfaceId: string): string | null {
18
- return surfaceFeatureGateId(surfaceId);
27
+ // Onboarding surface ids may be camelCase ('googleChat'); the SDK's gate
28
+ // map is keyed by the kebab-case ChannelSurface id ('google-chat'). Try the
29
+ // literal id first, then the kebab-case form, so choosing Google Chat in
30
+ // onboarding really flips google-chat-surface.
31
+ const literal = surfaceFeatureGateId(surfaceId);
32
+ if (literal) return literal;
33
+ const kebab = surfaceId.replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`);
34
+ return kebab === surfaceId ? null : surfaceFeatureGateId(kebab);
19
35
  }
20
36
 
21
37
  export function getServerSurfaceFeatureFlags(options: {
@@ -46,20 +62,35 @@ export function getServerSurfaceFeatureFlags(options: {
46
62
  return [...flags].sort((left, right) => left.localeCompare(right));
47
63
  }
48
64
 
49
- export function isFeatureFlagEnabled(config: Pick<ConfigManager, 'getCategory'>, flagId: string): boolean {
50
- const flags = (config.getCategory('featureFlags') as Record<string, PersistedFlagState | undefined>) ?? {};
51
- return flags[flagId] === 'enabled';
65
+ /** Whether the live config has the capability on, derived from its domain settings key. */
66
+ export function isFeatureFlagEnabled(config: Pick<ConfigManager, 'get'>, flagId: string): boolean {
67
+ return isFeatureConfigEnabled(config, flagId);
68
+ }
69
+
70
+ /**
71
+ * Translate internal feature-gate ids (e.g. 'control-plane-gateway') to the
72
+ * canonical domain settings keys the user actually sets (e.g.
73
+ * 'controlPlane.gateway') — matching the SDK's own gate errors, which name the
74
+ * settings key. A gate with no mapped setting falls back to its id. Deduped and
75
+ * sorted for stable output.
76
+ */
77
+ export function surfaceFeatureGateSettingsKeys(flagIds: readonly string[]): string[] {
78
+ const keys = new Set<string>();
79
+ for (const flagId of flagIds) keys.add(getFeatureSetting(flagId)?.enablement.key ?? flagId);
80
+ return [...keys].sort((left, right) => left.localeCompare(right));
52
81
  }
53
82
 
54
- export function getMissingSurfaceFeatureFlags(config: Pick<ConfigManager, 'getCategory'>, surfaceId: string): readonly string[] {
83
+ export function getMissingSurfaceFeatureFlags(config: Pick<ConfigManager, 'get'>, surfaceId: string): readonly string[] {
55
84
  const required = surfaceId === 'web'
56
85
  ? getServerSurfaceFeatureFlags({ web: true })
57
86
  : getServerSurfaceFeatureFlags({ externalSurfaces: [surfaceId] });
58
87
  return required.filter((flagId) => !isFeatureFlagEnabled(config, flagId));
59
88
  }
60
89
 
90
+ /** Switch each capability on by writing its real enablement settings key. */
61
91
  export function enableFeatureFlags(config: Pick<ConfigManager, 'setDynamic'>, flagIds: readonly string[]): void {
62
92
  for (const flagId of flagIds) {
63
- config.setDynamic(`featureFlags.${flagId}` as ConfigKey, 'enabled');
93
+ const write = featureEnablementWrite(flagId, true);
94
+ if (write) config.setDynamic(write.key, write.value);
64
95
  }
65
96
  }
@@ -45,7 +45,7 @@ export type TuiTerminalOutputGuardOptions = {
45
45
  * writes captured this session, so an honest quiet counter (e.g. /debug) can
46
46
  * refresh. The per-write detail is already recorded to the activity log via
47
47
  * logger.warn — this callback intentionally does NOT push transcript lines.
48
- * (UX-B item 1a.)
48
+ * (item 1a.)
49
49
  */
50
50
  readonly onCapture?: (total: number) => void;
51
51
  };
@@ -224,7 +224,7 @@ export function installTuiTerminalOutputGuard(options: TuiTerminalOutputGuardOpt
224
224
  onIntercept: () => {
225
225
  // Each intercept is already logged (logger.warn in record()). Here we only
226
226
  // maintain a cumulative counter and, rate-limited, refresh a quiet counter
227
- // surface — no repeated transcript lines. (UX-B item 1a.)
227
+ // surface — no repeated transcript lines. (item 1a.)
228
228
  totalInterceptedWrites++;
229
229
  const now = Date.now();
230
230
  if (now - lastNoticeAt < 5_000) return;
@@ -137,13 +137,21 @@ export class WorkspaceRegistrationManager {
137
137
  * Register the current working directory at its resolved project root. The
138
138
  * store refuses broad roots (WorkspaceRegistrationError) — we surface that
139
139
  * honestly rather than record a phantom registration.
140
+ *
141
+ * `origin` stamps which surface wrote the record (honest provenance). This
142
+ * NEVER passes `checkpointEligible`: absent means false, so a TUI self-record
143
+ * can never widen the checkpoint-owning consumer's boundary — only that
144
+ * consumer stamps its own roots eligible on boot.
140
145
  */
141
- async register(label?: string): Promise<
146
+ async register(label?: string, origin?: string): Promise<
142
147
  | { readonly registered: true; readonly result: RegisterWorkspaceResult }
143
148
  | { readonly registered: false; readonly refusedReason: string }
144
149
  > {
150
+ const opts = label || origin
151
+ ? { ...(label ? { label } : {}), ...(origin ? { origin } : {}) }
152
+ : undefined;
145
153
  try {
146
- const result = await this.store.add(this.workingDirectory, label ? { label } : undefined);
154
+ const result = await this.store.add(this.workingDirectory, opts);
147
155
  return { registered: true, result };
148
156
  } catch (error) {
149
157
  if (error instanceof WorkspaceRegistrationError) {
@@ -2,18 +2,24 @@
2
2
  * workspace-trust.ts — per-workspace trust gate.
3
3
  *
4
4
  * The first time GoodVibes opens a workspace (a cwd / project root it has no
5
- * prior state for), that workspace is "restricted": only read-category tools
6
- * run; write, execute, and delegate tools are denied until the user marks the
7
- * workspace trusted. This is NOT a parallel permission checker — the decision
8
- * is consulted by the real permission machinery at its final ask layer (the
9
- * requestPermission callback wired in bootstrap-core.ts), so it composes with,
10
- * and cannot drift from, the existing PermissionManager layer chain.
5
+ * prior decision for), that workspace is "undecided": only read-category
6
+ * tools run; the first write, execute, or delegate tool request raises the
7
+ * trust question AT THAT MOMENT — the exact point it has a real consequence —
8
+ * instead of silently failing or being decided by a side effect of the
9
+ * product's own droppings. This is NOT a parallel permission checker — the
10
+ * decision is consulted by the real permission machinery at its final ask
11
+ * layer (the requestPermission callback wired in bootstrap-core.ts), so it
12
+ * composes with, and cannot drift from, the existing PermissionManager layer
13
+ * chain.
11
14
  *
12
- * Trust is persisted per-workspace in <cwd>/.goodvibes/tui/trust.json. A
13
- * workspace that already carries GoodVibes runtime state (prior sessions,
14
- * checkpoints, state, memory, or a project onboarding marker) is grandfathered
15
- * as trusted on first load, so the gate only ever prompts for genuinely new
16
- * places.
15
+ * Trust is persisted per-workspace in <cwd>/.goodvibes/tui/trust.json. There
16
+ * is no more grandfathering: a workspace that already carries prior GoodVibes
17
+ * runtime state gets no special treatment (that side-effect-based shortcut
18
+ * used to paper over the fact that the first session's write attempts were
19
+ * silently denied without ever recording a real decision — now that the first
20
+ * attempt properly raises the question and persists the answer, the shortcut
21
+ * is unnecessary and would only hide a decision the user should actually see
22
+ * once, even on an upgrade).
17
23
  */
18
24
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
19
25
  import { join } from 'node:path';
@@ -25,13 +31,41 @@ type AskCallback = (request: PermissionPromptRequest) => Promise<PermissionPromp
25
31
 
26
32
  /**
27
33
  * Wrap the permission machinery's final ask callback with the workspace trust
28
- * gate: in an untrusted workspace, deny any non-read tool outright instead of
29
- * asking. This runs inside PermissionManager's own layer chain (it IS the ask
34
+ * gate. This runs inside PermissionManager's own layer chain (it IS the ask
30
35
  * layer), so it composes with — and cannot drift from — the real machinery.
36
+ *
37
+ * - Trusted workspace: every category passes through to `ask` unchanged.
38
+ * - Restricted workspace with an EXPLICIT prior decision: non-read categories
39
+ * are denied outright, same as before — that IS what "restricted" means
40
+ * once the user has actually chosen it.
41
+ * - Undecided workspace (no decision yet, ever): the first non-read request
42
+ * raises `requestTrustDecision()` — one modal, persisted via `setLevel` —
43
+ * and, if the answer is 'trusted', the ORIGINAL request is forwarded to
44
+ * `ask` so the very thing the user just approved actually happens instead
45
+ * of failing anyway. Concurrent requests that arrive before the first
46
+ * decision resolves await the same in-flight prompt rather than opening a
47
+ * second one.
31
48
  */
32
- export function trustGatedAsk(manager: Pick<WorkspaceTrustManager, 'isCategoryAllowed'>, ask: AskCallback): AskCallback {
33
- return (request) =>
34
- manager.isCategoryAllowed(request.category) ? ask(request) : Promise.resolve({ approved: false });
49
+ export function trustGatedAsk(
50
+ manager: Pick<WorkspaceTrustManager, 'isCategoryAllowed' | 'isDecided' | 'setLevel'>,
51
+ ask: AskCallback,
52
+ requestTrustDecision: () => Promise<WorkspaceTrustLevel>,
53
+ ): AskCallback {
54
+ let pendingDecision: Promise<WorkspaceTrustLevel> | null = null;
55
+ return async (request) => {
56
+ if (manager.isCategoryAllowed(request.category)) return ask(request);
57
+ if (!manager.isDecided()) {
58
+ if (!pendingDecision) {
59
+ pendingDecision = requestTrustDecision().finally(() => {
60
+ pendingDecision = null;
61
+ });
62
+ }
63
+ const level = await pendingDecision;
64
+ await manager.setLevel(level);
65
+ if (manager.isCategoryAllowed(request.category)) return ask(request);
66
+ }
67
+ return { approved: false };
68
+ };
35
69
  }
36
70
 
37
71
  export type WorkspaceTrustLevel = 'trusted' | 'restricted';
@@ -115,8 +149,6 @@ export function readPersistedWorkspaceTrust(paths: WorkspaceTrustPaths): Persist
115
149
 
116
150
  export interface WorkspaceTrustManagerOptions {
117
151
  readonly shellPaths: WorkspaceTrustPaths;
118
- /** Whether the workspace already had prior GoodVibes runtime state at startup. */
119
- readonly hadPriorState: boolean;
120
152
  }
121
153
 
122
154
  export class WorkspaceTrustManager {
@@ -124,20 +156,22 @@ export class WorkspaceTrustManager {
124
156
  private grandfathered = false;
125
157
  private loaded = false;
126
158
  private readonly store: JsonFileStore<PersistedWorkspaceTrust>;
127
- private readonly hadPriorState: boolean;
128
159
 
129
160
  constructor(options: WorkspaceTrustManagerOptions) {
130
161
  this.store = new JsonFileStore<PersistedWorkspaceTrust>(
131
162
  options.shellPaths.resolveProjectPath('tui', TRUST_FILE),
132
163
  );
133
- this.hadPriorState = options.hadPriorState;
134
164
  }
135
165
 
136
166
  /**
137
- * Load the persisted decision. If none exists, grandfather a workspace that
138
- * already had prior GoodVibes state (persisting the decision so it is never
139
- * re-evaluated); otherwise the workspace stays undecided and the gate treats
140
- * it as restricted until the user chooses.
167
+ * Load the persisted decision, if one exists. No grandfathering: a
168
+ * workspace with no persisted decision stays undecided (the gate treats it
169
+ * as restricted, and the first non-read tool request raises the trust
170
+ * question via `trustGatedAsk`'s `requestTrustDecision` callback) — even
171
+ * one that already carries prior GoodVibes runtime state. `grandfathered`
172
+ * on an already-persisted decision is read-only history from before this
173
+ * fix (status/doctor still report it honestly); nothing new is ever
174
+ * grandfathered.
141
175
  */
142
176
  async load(): Promise<void> {
143
177
  if (this.loaded) return;
@@ -145,13 +179,6 @@ export class WorkspaceTrustManager {
145
179
  if (persisted && (persisted.level === 'trusted' || persisted.level === 'restricted')) {
146
180
  this.level = persisted.level;
147
181
  this.grandfathered = persisted.grandfathered ?? false;
148
- this.loaded = true;
149
- return;
150
- }
151
- if (this.hadPriorState) {
152
- this.level = 'trusted';
153
- this.grandfathered = true;
154
- await this.persist();
155
182
  }
156
183
  this.loaded = true;
157
184
  }