@pellux/goodvibes-tui 1.13.1 → 1.15.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 (158) hide show
  1. package/CHANGELOG.md +104 -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 +412 -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 +109 -0
  141. package/src/runtime/services.ts +33 -33
  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/gateway-verbs.ts +0 -47
  158. package/src/runtime/render-scheduler.ts +0 -90
@@ -8,6 +8,7 @@ import {
8
8
  isExternalSurfaceSelectedByDefault,
9
9
  type ExternalSurfaceSpec,
10
10
  } from './onboarding-wizard-external-surfaces.ts';
11
+ import { buildFeatureUnitSteps } from './onboarding-feature-units.ts';
11
12
  import { countSelected, modelSelectionLabel, normalizeText } from './onboarding-wizard-helpers.ts';
12
13
  import { getDaemonSource, pushDaemonAdoptionFields, pushLegacyDaemonMigrationFields } from './onboarding-wizard-network-adopt.ts';
13
14
  import type { OnboardingWizardControllerLike } from './onboarding-wizard-types.ts';
@@ -37,10 +38,9 @@ export function buildOnboardingWizardSteps(controller: OnboardingWizardControlle
37
38
  if (shouldShowCloudflareStep(controller)) {
38
39
  steps.push(buildCloudflareStep(controller));
39
40
  }
40
- steps.push(buildProviderAccessStep(controller));
41
- steps.push(buildDefaultModelStep(controller));
42
- steps.push(buildExperienceStep(controller));
43
- steps.push(buildReviewStep(controller));
41
+ steps.push(buildProviderAccessStep(controller), buildDefaultModelStep(controller), buildExperienceStep(controller));
42
+ // Guided feature coverage: every remaining feature flag is reachable here as a unit.
43
+ steps.push(...buildFeatureUnitSteps(controller), buildReviewStep(controller));
44
44
  return steps.map(addApplyAndContinueAction);
45
45
  }
46
46
 
@@ -627,7 +627,7 @@ export function buildNetworkStep(controller: OnboardingWizardControllerLike): On
627
627
  const cpOff = controlPlaneRemote && String(controller.runtimeSnapshot?.config.controlPlane?.tls?.mode ?? 'off') === 'off';
628
628
  const hlOff = listenerEnabled && String(controller.runtimeSnapshot?.config.httpListener?.tls?.mode ?? 'off') === 'off';
629
629
  if (cpOff || hlOff) { const a = [cpOff ? 'control plane' : '', hlOff ? 'HTTP listener' : ''].filter(Boolean).join(' and '); fields.push({ kind: 'status', id: 'network.tls-warn', label: `TLS off — ${a} transmits plaintext`, defaultValue: 'Warning', hint: `The ${a} is network-reachable but TLS is off. Traffic travels in plaintext. Enable TLS or use a terminating reverse proxy.` }); }
630
- if (listenerEnabled) { fields.push({ kind: 'status', id: 'network.cors-note', label: 'CORS must be configured manually', defaultValue: 'Info', hint: 'httpListener.enforceCors and httpListener.allowedOrigins are not in ConfigKey union (SDK handoff Item 5). Edit ~/.goodvibes/tui/settings.json to set them, then restart the daemon.' }); }
630
+ if (listenerEnabled) { fields.push({ kind: 'status', id: 'network.cors-note', label: 'Browser CORS is off by default', defaultValue: 'Info', hint: 'The webhook/control-plane HTTP listener honors controlPlane.cors.enabled and controlPlane.cors.allowedOrigins. Set both from Settings > Control Plane (or /config controlPlane) to allow browser origins, then restart the daemon. CORS stays off until you enable it.' }); }
631
631
  }
632
632
  return {
633
633
  id: 'network',
@@ -26,6 +26,12 @@ export type OnboardingWizardStepId =
26
26
  | 'provider-access'
27
27
  | 'default-model'
28
28
  | 'experience'
29
+ | 'features-safety'
30
+ | 'features-context'
31
+ | 'features-telemetry'
32
+ | 'features-automation'
33
+ | 'features-provider'
34
+ | 'features-advanced'
29
35
  | 'review';
30
36
 
31
37
  export type OnboardingWizardFieldKind =
@@ -0,0 +1,35 @@
1
+ /**
2
+ * relay-settings-descriptions.ts — appends the relay threat-model one-liner to
3
+ * the `relay.*` CONFIG_SCHEMA descriptions shown in /config and the settings
4
+ * modal.
5
+ *
6
+ * `relay.*` is a real SDK CONFIG_SCHEMA domain (schema-domain-runtime.ts) —
7
+ * the settings-modal-data.ts CONFIG_SCHEMA loop already surfaces it once
8
+ * `relay` is listed in SETTINGS_CATEGORIES (see settings-modal-types.ts). The
9
+ * SDK's own descriptions explain WHAT each key does but not the trust
10
+ * boundary the outbound relay path crosses; this enrichment appends that
11
+ * honestly, once, without duplicating or overriding the SDK's wording. Entries
12
+ * are shallow-cloned (never mutating the shared `ConfigSetting` objects the
13
+ * SDK exports from CONFIG_SCHEMA) so re-running this on the same groups map
14
+ * is idempotent.
15
+ */
16
+
17
+ import type { SettingEntry, SettingsCategory } from './settings-modal-types.ts';
18
+
19
+ /** The threat-model note every relay.* setting description gets appended with. */
20
+ export const RELAY_THREAT_MODEL_NOTE =
21
+ 'The relay operator sees only ciphertext and connection metadata — it cannot read message contents. Self-host your own relay for full control.';
22
+
23
+ /** Append the threat-model note to every relay.* entry's description, in place. */
24
+ export function enrichRelaySettingDescriptions(groups: Map<SettingsCategory, SettingEntry[]>): void {
25
+ const relayEntries = groups.get('relay');
26
+ if (!relayEntries) return;
27
+ for (let i = 0; i < relayEntries.length; i++) {
28
+ const entry = relayEntries[i];
29
+ if (!entry || !entry.setting.key.startsWith('relay.') || entry.setting.description.includes(RELAY_THREAT_MODEL_NOTE)) continue;
30
+ relayEntries[i] = {
31
+ ...entry,
32
+ setting: { ...entry.setting, description: `${entry.setting.description} ${RELAY_THREAT_MODEL_NOTE}` },
33
+ };
34
+ }
35
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * sandbox-exec-config.ts — TUI-local synthetic settings entries for
3
+ * sandbox.egressAllowlist / sandbox.workspaceWritable.
4
+ *
5
+ * The SDK's per-command exec sandbox (bubblewrap on Linux, gated by the
6
+ * graduation-tracked `exec-sandbox` feature flag) reads its full configuration
7
+ * straight off `configManager.getCategory('sandbox')` inside
8
+ * `registerAllTools` (platform/tools/index.ts) — `sandbox.enabled`,
9
+ * `sandbox.egressAllowlist`, `sandbox.workspaceWritable`. `sandbox.enabled` is
10
+ * already a real CONFIG_SCHEMA key (with its own honest "requires bubblewrap
11
+ * on Linux, reports unavailable elsewhere" description) and needs no synthetic
12
+ * entry — the CONFIG_SCHEMA loop in settings-modal-data.ts already surfaces it.
13
+ * `egressAllowlist`/`workspaceWritable` are NOT in CONFIG_SCHEMA, so this file
14
+ * mirrors worktree-setup-config.ts's synthetic-entry pattern for them.
15
+ *
16
+ * UNLIKE worktree.setup.* / learning.consolidation.*, no defensive try/catch is
17
+ * needed here: DEFAULT_CONFIG.sandbox already carries both fields (as empty
18
+ * arrays), so ConfigManager.resolvePath finds the `sandbox` section on a plain
19
+ * get()/set() without throwing — only a missing SECTION throws, and `sandbox`
20
+ * has existed since before this repack (it backs the older VM/REPL isolation
21
+ * settings). Writing through these entries takes effect for real: the exec
22
+ * sandbox reads the same `sandbox` config category at tool-registration time.
23
+ *
24
+ * Both keys hold a JSON array of strings on disk. The settings modal's inline
25
+ * editor is a single-line text field, so — matching worktree.setup.* and
26
+ * controlPlane.cors.allowedOrigins — they are displayed and edited as a
27
+ * comma-separated list and parsed back into an array on commit (see
28
+ * isSandboxExecListConfigKey's use in settings-modal.ts#commitEdit).
29
+ */
30
+ import type { ConfigKey, ConfigManager, ConfigSetting } from '@pellux/goodvibes-sdk/platform/config';
31
+ import type { SettingEntry, SettingsCategory } from './settings-modal-types.ts';
32
+
33
+ export const SANDBOX_EGRESS_ALLOWLIST_CONFIG_KEY = 'sandbox.egressAllowlist' as ConfigKey;
34
+ export const SANDBOX_WORKSPACE_WRITABLE_CONFIG_KEY = 'sandbox.workspaceWritable' as ConfigKey;
35
+
36
+ const SANDBOX_EXEC_LIST_CONFIG_KEYS: ReadonlySet<ConfigKey> = new Set([
37
+ SANDBOX_EGRESS_ALLOWLIST_CONFIG_KEY,
38
+ SANDBOX_WORKSPACE_WRITABLE_CONFIG_KEY,
39
+ ]);
40
+
41
+ /** True for the two sandbox exec-boundary list keys, whose stored value is a string array edited as a comma-separated list. */
42
+ export function isSandboxExecListConfigKey(key: ConfigKey): boolean {
43
+ return SANDBOX_EXEC_LIST_CONFIG_KEYS.has(key);
44
+ }
45
+
46
+ /** Read a config value as a string array (non-array/malformed degrades to empty). */
47
+ export function readSandboxExecList(configManager: Pick<ConfigManager, 'get'>, key: ConfigKey): string[] {
48
+ const raw = configManager.get(key);
49
+ if (!Array.isArray(raw)) return [];
50
+ return raw.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0);
51
+ }
52
+
53
+ /** Parse a comma-separated edit-buffer submission back into a string array (trimmed, empties dropped). Shared with worktree.setup.* — same convention. */
54
+ export function parseSandboxExecListInput(text: string): string[] {
55
+ return text.split(',').map((part) => part.trim()).filter((part) => part.length > 0);
56
+ }
57
+
58
+ export const SANDBOX_EGRESS_ALLOWLIST_SYNTHETIC_SETTING: ConfigSetting = {
59
+ key: SANDBOX_EGRESS_ALLOWLIST_CONFIG_KEY,
60
+ type: 'string',
61
+ default: [],
62
+ description: 'Comma-separated command base names (or "*" for all) whose network access is re-enabled inside the per-command exec sandbox boundary, as a named escalation the approval flow surfaces ("wants network"). Empty = the sandbox disables network for every command. Only takes effect when sandbox.enabled is on, the graduation-tracked exec-sandbox feature flag is on, and bubblewrap is available (Linux only — reports unavailable elsewhere).',
63
+ };
64
+
65
+ export const SANDBOX_WORKSPACE_WRITABLE_SYNTHETIC_SETTING: ConfigSetting = {
66
+ key: SANDBOX_WORKSPACE_WRITABLE_CONFIG_KEY,
67
+ type: 'string',
68
+ default: [],
69
+ description: 'Comma-separated absolute paths outside the workspace bound writable into the per-command exec sandbox boundary, as a named escalation the approval flow surfaces ("wants path outside workspace"). Empty = only the workspace (and an isolated /tmp) is writable. Only takes effect when sandbox.enabled is on, the graduation-tracked exec-sandbox feature flag is on, and bubblewrap is available (Linux only — reports unavailable elsewhere).',
70
+ };
71
+
72
+ function buildSandboxExecListEntry(configManager: Pick<ConfigManager, 'get'>, setting: ConfigSetting): SettingEntry {
73
+ const currentValue = readSandboxExecList(configManager, setting.key);
74
+ return {
75
+ setting,
76
+ currentValue,
77
+ isDefault: currentValue.length === 0,
78
+ };
79
+ }
80
+
81
+ export function buildSandboxEgressAllowlistSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
82
+ return buildSandboxExecListEntry(configManager, SANDBOX_EGRESS_ALLOWLIST_SYNTHETIC_SETTING);
83
+ }
84
+
85
+ export function buildSandboxWorkspaceWritableSyntheticEntry(configManager: Pick<ConfigManager, 'get'>): SettingEntry {
86
+ return buildSandboxExecListEntry(configManager, SANDBOX_WORKSPACE_WRITABLE_SYNTHETIC_SETTING);
87
+ }
88
+
89
+ /**
90
+ * Inject both synthetic entries into the sandbox category's SettingEntry list
91
+ * (alongside the real sandbox.enabled CONFIG_SCHEMA entry the caller's own
92
+ * CONFIG_SCHEMA loop already added), idempotently. No-op when the category
93
+ * isn't present in `groups` at all.
94
+ */
95
+ export function injectSandboxExecSyntheticEntries(
96
+ groups: Map<SettingsCategory, SettingEntry[]>,
97
+ configManager: Pick<ConfigManager, 'get'>,
98
+ ): void {
99
+ const sandboxEntries = groups.get('sandbox');
100
+ if (!sandboxEntries) return;
101
+ if (!sandboxEntries.some((e) => e.setting.key === SANDBOX_EGRESS_ALLOWLIST_CONFIG_KEY)) {
102
+ sandboxEntries.push(buildSandboxEgressAllowlistSyntheticEntry(configManager));
103
+ }
104
+ if (!sandboxEntries.some((e) => e.setting.key === SANDBOX_WORKSPACE_WRITABLE_CONFIG_KEY)) {
105
+ sandboxEntries.push(buildSandboxWorkspaceWritableSyntheticEntry(configManager));
106
+ }
107
+ }
@@ -45,13 +45,10 @@ export function adjustSelected(
45
45
  ): void {
46
46
  if (ctx.editingMode) return;
47
47
 
48
- if (ctx.currentCategory === 'flags') {
49
- const flagEntry = ctx.getSelectedFlag();
50
- if (!flagEntry || flagEntry.state === 'killed' || !ctx.featureFlagManager || !ctx.configManager) return;
51
- const targetState: FlagState = direction === 'right' ? 'enabled' : 'disabled';
52
- if (flagEntry.state !== targetState) applyFlagState(flagEntry, targetState, ctx.featureFlagManager, ctx.configManager);
53
- return;
54
- }
48
+ // Feature-unit toggle headers (setting.type === 'boolean', key featureFlags.<id>)
49
+ // now live across topical categories, not a single flags tab. They fall through
50
+ // to the boolean branch below, which routes through setValue → the modal's
51
+ // flag-toggle chokepoint. No category-special-case is needed here anymore.
55
52
 
56
53
  if (ctx.currentCategory === 'mcp') {
57
54
  const entry = ctx.getSelectedMcp();
@@ -15,7 +15,26 @@ import { buildSubscriptionEntries } from './settings-modal-subscriptions.ts';
15
15
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
16
16
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
17
17
  import { CODE_INDEX_ENABLED_CONFIG_KEY } from '../runtime/code-index-services.ts';
18
+ import {
19
+ WORKTREE_SETUP_CARRY_OVER_GLOBS_CONFIG_KEY,
20
+ WORKTREE_SETUP_COMMANDS_CONFIG_KEY,
21
+ buildWorktreeSetupCarryOverGlobsSyntheticEntry,
22
+ buildWorktreeSetupCommandsSyntheticEntry,
23
+ isWorktreeSetupListConfigKey,
24
+ readWorktreeSetupList,
25
+ } from './worktree-setup-config.ts';
18
26
  import { BUDGET_ALERT_USD_CONFIG_KEY, BUDGET_ALERT_USD_DEFAULT, readBudgetAlertUsd } from '../export/cost-utils.ts';
27
+ import { MEMORY_PROJECTION_DIR_CONFIG_KEY, buildMemoryProjectionDirSyntheticEntry } from './commands/recall-files-config.ts';
28
+ import {
29
+ injectSandboxExecSyntheticEntries,
30
+ isSandboxExecListConfigKey,
31
+ readSandboxExecList,
32
+ } from './sandbox-exec-config.ts';
33
+ import {
34
+ injectExecEnvScrubSyntheticEntry,
35
+ isExecEnvScrubAllowlistConfigKey,
36
+ readExecEnvScrubAllowlist,
37
+ } from './exec-env-scrub-config.ts';
19
38
  import {
20
39
  THEME_MODE_CONFIG_KEY,
21
40
  THEME_MODE_VALUES,
@@ -31,6 +50,8 @@ import {
31
50
  type SettingsCategory,
32
51
  type SubscriptionEntry,
33
52
  } from './settings-modal-types.ts';
53
+ import { enrichRelaySettingDescriptions } from './relay-settings-descriptions.ts';
54
+ import { applyFeatureUnitLayout } from './feature-unit-layout.ts';
34
55
 
35
56
  // ---------------------------------------------------------------------------
36
57
  // deepEqual — structural equality for isDefault comparisons
@@ -74,12 +95,13 @@ export function deepEqual(a: unknown, b: unknown): boolean {
74
95
 
75
96
  export function buildSettingGroups(
76
97
  configManager: ConfigManager,
98
+ featureFlagManager?: FeatureFlagManager | null,
77
99
  ): Map<SettingsCategory, SettingEntry[]> {
78
100
  const groups = new Map<SettingsCategory, SettingEntry[]>();
79
- for (const cat of SETTINGS_CATEGORIES) {
80
- if (cat === 'flags') continue;
81
- groups.set(cat, []);
82
- }
101
+ // Every category starts empty (including 'flags', now the Advanced Features
102
+ // bucket, and the new fetch/agents/security/integrations/policy/notifications
103
+ // namespaces) and is filled from CONFIG_SCHEMA below.
104
+ for (const cat of SETTINGS_CATEGORIES) groups.set(cat, []);
83
105
 
84
106
  for (const setting of CONFIG_SCHEMA) {
85
107
  const rawCat = setting.key.split('.')[0] as string;
@@ -167,6 +189,13 @@ export function buildSettingGroups(
167
189
  behaviorEntries.push(entry);
168
190
  }
169
191
  }
192
+ // In-terminal (OSC 9) notification toggles + audible bell — same category,
193
+ // per-key defaults (see terminal-notifier.ts).
194
+ for (const entry of buildTerminalNotifySyntheticEntries(configManager)) {
195
+ if (!behaviorEntries.some((e) => e.setting.key === entry.setting.key)) {
196
+ behaviorEntries.push(entry);
197
+ }
198
+ }
170
199
  }
171
200
 
172
201
  // Inject the storage.codeIndexEnabled toggle into the
@@ -178,6 +207,50 @@ export function buildSettingGroups(
178
207
  storageEntries.push(buildCodeIndexEnabledSyntheticEntry(configManager));
179
208
  }
180
209
 
210
+ // Inject the memory.projection.dir synthetic entry into the storage
211
+ // category (alongside codeIndexEnabled — both are local-storage posture
212
+ // settings). Not in the SDK ConfigKey union, same rationale as above.
213
+ if (storageEntries && !storageEntries.some((e) => e.setting.key === MEMORY_PROJECTION_DIR_CONFIG_KEY)) {
214
+ storageEntries.push(buildMemoryProjectionDirSyntheticEntry(configManager));
215
+ }
216
+
217
+ // Inject the worktree.setup.commands / worktree.setup.carryOverGlobs
218
+ // synthetic entries into the orchestration category (worktree isolation is
219
+ // an orchestration-engine concern — see WorkstreamIsolation 'worktree').
220
+ // Neither key is in the SDK ConfigKey union (see worktree-setup-config.ts),
221
+ // same rationale as the other synthetic settings above.
222
+ const orchestrationEntries = groups.get('orchestration');
223
+ if (orchestrationEntries) {
224
+ if (!orchestrationEntries.some((e) => e.setting.key === WORKTREE_SETUP_COMMANDS_CONFIG_KEY)) {
225
+ orchestrationEntries.push(buildWorktreeSetupCommandsSyntheticEntry(configManager));
226
+ }
227
+ if (!orchestrationEntries.some((e) => e.setting.key === WORKTREE_SETUP_CARRY_OVER_GLOBS_CONFIG_KEY)) {
228
+ orchestrationEntries.push(buildWorktreeSetupCarryOverGlobsSyntheticEntry(configManager));
229
+ }
230
+ }
231
+
232
+ // Inject the sandbox.egressAllowlist / sandbox.workspaceWritable and
233
+ // permissions.execEnvScrubAllowlist synthetic entries — see
234
+ // sandbox-exec-config.ts / exec-env-scrub-config.ts for why they are not in
235
+ // CONFIG_SCHEMA, same rationale as the other synthetic settings above.
236
+ injectSandboxExecSyntheticEntries(groups, configManager);
237
+ injectExecEnvScrubSyntheticEntry(groups, configManager);
238
+
239
+ // relay.* is a real SDK CONFIG_SCHEMA domain; append the threat-model note
240
+ // the SDK's own descriptions don't carry (see relay-settings-descriptions.ts).
241
+ enrichRelaySettingDescriptions(groups);
242
+
243
+ // Feature-unit presentation: turn each flag into ONE unit (toggle + the config
244
+ // keys it tunes) hosted in its topical category, folding no-config flags into
245
+ // the Advanced Features bucket. Sourced from FEATURE_FLAG_CONFIG. Runs AFTER
246
+ // synthetic injection so non-flag-owned rows stay as orphans; skipped on the
247
+ // flagless test path.
248
+ if (featureFlagManager) applyFeatureUnitLayout(groups, buildFlagEntries(featureFlagManager));
249
+
250
+ // learning.consolidation.* is a real SDK config domain now, so its keys arrive
251
+ // through the CONFIG_SCHEMA loop above; the old TUI-local synthetic entries were
252
+ // removed for this reason.
253
+
181
254
  return groups;
182
255
  }
183
256
 
@@ -395,6 +468,48 @@ export function buildNotifyAlertSyntheticEntries(configManager: Pick<ConfigManag
395
468
  return NOTIFY_ALERT_SYNTHETIC_SETTINGS.map((spec) => buildBooleanSyntheticEntry(configManager, spec.key, spec.description, true));
396
469
  }
397
470
 
471
+ // ---------------------------------------------------------------------------
472
+ // In-terminal (OSC 9) notification settings — see core/terminal-notifier.ts
473
+ // ---------------------------------------------------------------------------
474
+
475
+ /**
476
+ * The OSC 9 in-terminal desktop-notification toggles + the audible bell.
477
+ * TUI-local synthetic booleans (not in the SDK ConfigKey union), each carrying
478
+ * its OWN default (unlike the always-on W2.3 alert toggles above): approval-wait
479
+ * on, turn-end/agent-blocked off, bell off. Read generically by
480
+ * core/terminal-notifier.ts (readBooleanConfig); this is only the
481
+ * settings-modal-visible surface. These are independent of the master
482
+ * `notifyOnlyWhenUnfocused` gate — an in-terminal notification is always
483
+ * focus-gated on its own (only fires for an unfocused/unknown terminal).
484
+ */
485
+ const TERMINAL_NOTIFY_SYNTHETIC_SETTINGS: ReadonlyArray<{ readonly key: string; readonly description: string; readonly defaultOn: boolean }> = [
486
+ {
487
+ key: 'behavior.terminalNotifyApprovalWait',
488
+ description: 'Send an in-terminal (OSC 9) desktop notification when a tool call is waiting on your approval. Fires only when the terminal window is unfocused. Works over SSH/tmux where OS notifications do not.',
489
+ defaultOn: true,
490
+ },
491
+ {
492
+ key: 'behavior.terminalNotifyTurnEnd',
493
+ description: 'Send an in-terminal (OSC 9) desktop notification when a turn finishes. Off by default. Fires only when the terminal window is unfocused.',
494
+ defaultOn: false,
495
+ },
496
+ {
497
+ key: 'behavior.terminalNotifyAgentBlocked',
498
+ description: 'Send an in-terminal (OSC 9) desktop notification when a delegated agent blocks waiting for your input. Off by default. Fires only when the terminal window is unfocused.',
499
+ defaultOn: false,
500
+ },
501
+ {
502
+ key: 'behavior.terminalBell',
503
+ description: 'Also ring the terminal bell alongside in-terminal notifications. Off by default.',
504
+ defaultOn: false,
505
+ },
506
+ ];
507
+
508
+ /** Build the OSC 9 in-terminal notification SettingEntry rows for the behavior category. */
509
+ export function buildTerminalNotifySyntheticEntries(configManager: Pick<ConfigManager, 'get'>): SettingEntry[] {
510
+ return TERMINAL_NOTIFY_SYNTHETIC_SETTINGS.map((spec) => buildBooleanSyntheticEntry(configManager, spec.key, spec.description, spec.defaultOn));
511
+ }
512
+
398
513
  // ---------------------------------------------------------------------------
399
514
  // storage.codeIndexEnabled synthetic setting
400
515
  // ---------------------------------------------------------------------------
@@ -422,10 +537,9 @@ export function buildCodeIndexEnabledSyntheticEntry(configManager: Pick<ConfigMa
422
537
 
423
538
  export function buildFlagEntries(featureFlagManager: FeatureFlagManager | null): FlagEntry[] {
424
539
  if (!featureFlagManager) return [];
425
- return Array.from(featureFlagManager.getAll().values()).map(({ flag, state }) => ({
426
- flag,
427
- state,
428
- }));
540
+ return Array.from(featureFlagManager.getAll().values()).map(
541
+ ({ flag, state, persistedState, pendingRestart }) => ({ flag, state, persistedState, pendingRestart }),
542
+ );
429
543
  }
430
544
 
431
545
  // ---------------------------------------------------------------------------
@@ -492,6 +606,46 @@ export function refreshEntryValues(
492
606
  ): void {
493
607
  for (const entries of groups.values()) {
494
608
  for (const entry of entries) {
609
+ // The worktree.setup.* keys read through a defensive helper. The SDK now
610
+ // registers the 'worktree' section in DEFAULT_CONFIG, so a plain
611
+ // configManager.get returns the empty default rather than throwing; the
612
+ // helper's guard (belt-and-suspenders for an older SDK) keeps this
613
+ // refresh from crashing for EVERY setting change if the section is ever
614
+ // absent (see worktree-setup-config.ts).
615
+ if (isWorktreeSetupListConfigKey(entry.setting.key)) {
616
+ entry.currentValue = readWorktreeSetupList(configManager, entry.setting.key);
617
+ entry.isDefault = (entry.currentValue as string[]).length === 0;
618
+ continue;
619
+ }
620
+ // sandbox.egressAllowlist / sandbox.workspaceWritable: 'sandbox' IS a real
621
+ // DEFAULT_CONFIG section (has been since before this repack, backing the
622
+ // VM/REPL isolation settings), so a plain get() would not throw — but an
623
+ // unset leaf still reads back as `undefined`, not `[]`, so this still needs
624
+ // the same array-normalizing read as the worktree keys above (see
625
+ // sandbox-exec-config.ts).
626
+ if (isSandboxExecListConfigKey(entry.setting.key)) {
627
+ entry.currentValue = readSandboxExecList(configManager, entry.setting.key);
628
+ entry.isDefault = (entry.currentValue as string[]).length === 0;
629
+ continue;
630
+ }
631
+ // permissions.execEnvScrubAllowlist: same normalizing-read trap as above —
632
+ // 'permissions' is a real section but this leaf is TUI-local (see
633
+ // exec-env-scrub-config.ts).
634
+ if (isExecEnvScrubAllowlistConfigKey(entry.setting.key)) {
635
+ entry.currentValue = readExecEnvScrubAllowlist(configManager);
636
+ entry.isDefault = (entry.currentValue as string[]).length === 0;
637
+ continue;
638
+ }
639
+ // 'learning' is now a real DEFAULT_CONFIG section (schema-domain-learning.ts),
640
+ // so learning.consolidation.* keys read through the plain path below like any
641
+ // other schema-driven setting — no special case needed anymore.
642
+ // Same trap still applies here: 'memory' has no DEFAULT_CONFIG entry.
643
+ if (entry.setting.key === MEMORY_PROJECTION_DIR_CONFIG_KEY) {
644
+ const refreshed = buildMemoryProjectionDirSyntheticEntry(configManager);
645
+ entry.currentValue = refreshed.currentValue;
646
+ entry.isDefault = refreshed.isDefault;
647
+ continue;
648
+ }
495
649
  const raw = configManager.get(entry.setting.key as ConfigKey);
496
650
  // Synthetic entries (e.g. tts.speed) that have no SDK schema key return
497
651
  // undefined from configManager. Normalize using the same logic used at
@@ -520,6 +674,22 @@ export function updateEntryForKey(
520
674
  for (const entries of groups.values()) {
521
675
  const entry = entries.find((candidate) => candidate.setting.key === key);
522
676
  if (entry) {
677
+ if (key === MEMORY_PROJECTION_DIR_CONFIG_KEY) {
678
+ const refreshed = buildMemoryProjectionDirSyntheticEntry(configManager);
679
+ entry.currentValue = refreshed.currentValue;
680
+ entry.isDefault = refreshed.isDefault;
681
+ continue;
682
+ }
683
+ if (isSandboxExecListConfigKey(key)) {
684
+ entry.currentValue = readSandboxExecList(configManager, key);
685
+ entry.isDefault = (entry.currentValue as string[]).length === 0;
686
+ continue;
687
+ }
688
+ if (isExecEnvScrubAllowlistConfigKey(key)) {
689
+ entry.currentValue = readExecEnvScrubAllowlist(configManager);
690
+ entry.isDefault = (entry.currentValue as string[]).length === 0;
691
+ continue;
692
+ }
523
693
  const raw = configManager.get(key);
524
694
  // Synthetic entries: normalize using the same fallback logic as construction.
525
695
  entry.currentValue = key === ('tts.speed' as ConfigKey)
@@ -54,7 +54,19 @@ export function applySettingValue({
54
54
  /** Called after applying the value so the caller can re-read currentValues. */
55
55
  refreshGroups: () => void;
56
56
  }): ApplyValueResult {
57
- const previousValue = configManager.get(key);
57
+ // Defensive: a handful of TUI-local synthetic keys (see worktree-setup-config.ts)
58
+ // live under a config section CONFIG_SCHEMA/DEFAULT_CONFIG has never populated
59
+ // (e.g. 'worktree' as of the SDK 1.6.1 repack), so configManager.get can throw
60
+ // "Invalid config path" here even though the write attempt below is already
61
+ // guarded. Without this, that read — which runs unconditionally, before the
62
+ // write's own try/catch — would crash the whole settings modal on save instead
63
+ // of surfacing the honest "Save failed" message the write path below produces.
64
+ let previousValue: unknown;
65
+ try {
66
+ previousValue = configManager.get(key);
67
+ } catch (e) {
68
+ logger.error('SettingsModal: failed to read previous config value', { key, error: summarizeError(e) });
69
+ }
58
70
  // REQUIRES_RESTART: SDK's ConfigSetting has no requiresRestart field yet (see
59
71
  // goodvibes-sdk HANDOFF-FROM-TUI-SESSION-20260611.md §Item 8). Until it does,
60
72
  // we detect restart-triggering keys by sub-key name heuristic below.
@@ -139,21 +151,37 @@ export function applyFlagState(
139
151
  ): void {
140
152
  const flag: FeatureFlag = flagEntry.flag;
141
153
 
142
- if (!flag.runtimeToggleable) {
143
- persistFlagState(configManager, flag.id, newState, flag.defaultState as FlagState);
144
- flagEntry.state = newState;
145
- return;
146
- }
147
-
148
154
  try {
149
- if (newState === 'enabled') {
150
- featureFlagManager.enable(flag.id);
151
- } else {
152
- featureFlagManager.disable(flag.id);
153
- }
155
+ // Persist first so the on-disk override is the source of truth, then apply
156
+ // the same value to the live manager through applyConfigState. That single
157
+ // call keeps both paths consistent with the SDK's config→flag bridge:
158
+ // - runtime-toggleable flags apply immediately (firing flag subscribers
159
+ // exactly as a direct enable()/disable() would), and
160
+ // - startup-gated flags are NOT faked live — the effective state is left
161
+ // untouched and a pending-restart marker is recorded instead.
154
162
  persistFlagState(configManager, flag.id, newState, flag.defaultState as FlagState);
155
- flagEntry.state = newState;
163
+ featureFlagManager.applyConfigState(flag.id, newState);
156
164
  } catch (e) {
157
165
  logger.error('SettingsModal: failed to toggle feature flag', { flag: flag.id, error: summarizeError(e) });
158
166
  }
167
+
168
+ // Reflect the manager's real state, never the requested target: for a
169
+ // startup-gated flag `state` stays at the unchanged effective value and
170
+ // `pendingRestart` reports that a restart is needed for `persistedState`.
171
+ syncFlagEntryFromManager(flagEntry, featureFlagManager);
172
+ }
173
+
174
+ /**
175
+ * Refresh a FlagEntry's live/persisted/pending fields from the manager's
176
+ * authoritative snapshot so the settings UI never displays a guessed state.
177
+ */
178
+ export function syncFlagEntryFromManager(
179
+ flagEntry: FlagEntry,
180
+ featureFlagManager: FeatureFlagManager,
181
+ ): void {
182
+ const snapshot = featureFlagManager.getAll().get(flagEntry.flag.id);
183
+ if (!snapshot) return;
184
+ flagEntry.state = snapshot.state;
185
+ flagEntry.persistedState = snapshot.persistedState;
186
+ flagEntry.pendingRestart = snapshot.pendingRestart;
159
187
  }
@@ -9,6 +9,7 @@ export type SettingsCategory =
9
9
  | 'subscriptions'
10
10
  | 'behavior'
11
11
  | 'storage'
12
+ | 'atRest'
12
13
  | 'permissions'
13
14
  | 'orchestration'
14
15
  | 'planner'
@@ -22,16 +23,26 @@ export type SettingsCategory =
22
23
  | 'httpListener'
23
24
  | 'web'
24
25
  | 'network'
26
+ | 'relay'
25
27
  | 'surfaces'
26
28
  | 'cloudflare'
27
29
  | 'batch'
28
30
  | 'automation'
31
+ | 'checkin'
29
32
  | 'watchers'
30
33
  | 'runtime'
31
34
  | 'telemetry'
32
35
  | 'cache'
36
+ | 'diagnostics'
33
37
  | 'sandbox'
34
38
  | 'mcp'
39
+ | 'learning'
40
+ | 'fetch'
41
+ | 'agents'
42
+ | 'security'
43
+ | 'integrations'
44
+ | 'policy'
45
+ | 'notifications'
35
46
  | 'flags'
36
47
  | 'release'
37
48
  | 'danger';
@@ -42,12 +53,12 @@ export const SETTINGS_CATEGORY_GROUPS: ReadonlyArray<{
42
53
  readonly label: string;
43
54
  readonly categories: readonly SettingsCategory[];
44
55
  }> = [
45
- { label: 'Interface', categories: ['display', 'ui', 'behavior', 'permissions'] },
56
+ { label: 'Interface', categories: ['display', 'ui', 'behavior', 'notifications', 'permissions', 'policy', 'security'] },
46
57
  { label: 'AI Routing', categories: ['provider', 'subscriptions', 'helper', 'tools', 'tts'] },
47
- { label: 'Service & Network', categories: ['service', 'daemon', 'network', 'controlPlane', 'httpListener', 'web'] },
48
- { label: 'Surfaces & Cloud', categories: ['surfaces', 'mcp', 'cloudflare'] },
49
- { label: 'Automation', categories: ['batch', 'automation', 'watchers', 'orchestration', 'planner', 'wrfc'] },
50
- { label: 'Runtime & Data', categories: ['storage', 'sandbox', 'runtime', 'cache', 'telemetry'] },
58
+ { label: 'Service & Network', categories: ['service', 'daemon', 'network', 'controlPlane', 'httpListener', 'web', 'relay'] },
59
+ { label: 'Surfaces & Cloud', categories: ['surfaces', 'integrations', 'mcp', 'cloudflare'] },
60
+ { label: 'Automation', categories: ['batch', 'automation', 'checkin', 'watchers', 'orchestration', 'planner', 'wrfc'] },
61
+ { label: 'Runtime & Data', categories: ['storage', 'atRest', 'sandbox', 'fetch', 'agents', 'runtime', 'cache', 'telemetry', 'diagnostics', 'learning'] },
51
62
  { label: 'Advanced', categories: ['flags', 'release', 'danger'] },
52
63
  ];
53
64
 
@@ -62,11 +73,37 @@ export interface SettingEntry {
62
73
  conflict?: boolean;
63
74
  sourceLabel?: string;
64
75
  lockReason?: string;
76
+ /**
77
+ * Present when this row is a feature-unit toggle header (a feature flag
78
+ * rendered as one unit with its config keys beneath it). The row's
79
+ * setting.key is `featureFlags.<id>` and setting.type is 'boolean'; toggling
80
+ * routes to the feature-flag manager, not a plain config write.
81
+ */
82
+ flag?: FlagEntry;
83
+ /**
84
+ * Present when this row is a config sub-option OWNED by a feature unit — the
85
+ * id of the owning flag. Drives the indented rendering beneath its header and
86
+ * marks the row as claimed so it does not double-list as an orphan.
87
+ */
88
+ ownerFlagId?: string;
65
89
  }
66
90
 
67
91
  export interface FlagEntry {
68
92
  flag: FeatureFlag;
93
+ /** Live effective state as the runtime currently sees it. */
69
94
  state: FlagState;
95
+ /**
96
+ * Last known config-layer (persisted) value. Equals `state` unless a
97
+ * startup-gated flag was toggled this session — then it holds the value that
98
+ * will take effect on the next launch.
99
+ */
100
+ persistedState: FlagState;
101
+ /**
102
+ * True when a startup-gated flag's persisted value differs from its effective
103
+ * state, i.e. a restart is required before the change takes effect. Sourced
104
+ * from FeatureFlagManager.getAll(), never guessed.
105
+ */
106
+ pendingRestart: boolean;
70
107
  }
71
108
 
72
109
  export interface McpEntry {