@pellux/goodvibes-tui 0.19.28 → 0.19.30

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 (34) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +1 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/surface-command.ts +46 -11
  6. package/src/core/orchestrator.ts +5 -1
  7. package/src/daemon/cli.ts +7 -0
  8. package/src/input/handler-onboarding.ts +151 -44
  9. package/src/input/onboarding/handler-onboarding-routes.ts +4 -0
  10. package/src/input/onboarding/onboarding-wizard-apply.ts +35 -8
  11. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -5
  12. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +93 -5
  13. package/src/input/onboarding/onboarding-wizard-helpers.ts +2 -2
  14. package/src/input/onboarding/onboarding-wizard-rules.ts +22 -3
  15. package/src/input/onboarding/onboarding-wizard-state.ts +12 -7
  16. package/src/input/onboarding/onboarding-wizard-steps.ts +133 -59
  17. package/src/input/onboarding/onboarding-wizard-types.ts +10 -0
  18. package/src/input/onboarding/onboarding-wizard.ts +56 -4
  19. package/src/input/settings-modal-types.ts +2 -1
  20. package/src/input/settings-modal.ts +4 -0
  21. package/src/main.ts +33 -26
  22. package/src/renderer/compositor.ts +3 -3
  23. package/src/renderer/onboarding/onboarding-wizard.ts +38 -21
  24. package/src/renderer/settings-modal-helpers.ts +9 -0
  25. package/src/renderer/settings-modal.ts +3 -0
  26. package/src/runtime/bootstrap-core.ts +28 -3
  27. package/src/runtime/bootstrap.ts +20 -3
  28. package/src/runtime/onboarding/apply.ts +36 -8
  29. package/src/runtime/onboarding/derivation.ts +7 -7
  30. package/src/runtime/onboarding/snapshot.ts +1 -0
  31. package/src/runtime/onboarding/types.ts +4 -1
  32. package/src/runtime/onboarding/verify.ts +1 -1
  33. package/src/runtime/surface-feature-flags.ts +67 -0
  34. package/src/version.ts +1 -1
package/src/main.ts CHANGED
@@ -177,6 +177,7 @@ async function main() {
177
177
  };
178
178
 
179
179
  const getViewportHeight = (): number => {
180
+ if (input.onboardingWizard.active) return stdout.rows || 24;
180
181
  const promptLines: number = input.getVisiblePromptLineCount(getPromptContentWidth());
181
182
  const currentModel = providerRegistry.getCurrentModel();
182
183
  return (stdout.rows || 24) - 2 - estimateShellFooterHeight(promptLines, currentModel.contextWindow);
@@ -551,19 +552,23 @@ async function main() {
551
552
  composerPendingRisk: composerState.pendingRisk,
552
553
  }).lines;
553
554
 
555
+ const onboardingOwnsScreen = input.onboardingWizard.active;
556
+ const shellHeaderLines = onboardingOwnsScreen ? [] : headerLines;
557
+ const shellFooterLines = onboardingOwnsScreen ? [] : footerLines;
558
+ const panelWidth = !onboardingOwnsScreen && panelManager.isVisible() && panelManager.getAllOpen().length > 0
559
+ ? panelManager.getRightWidth(width)
560
+ : 0;
554
561
  const shellLayout = createShellLayout({
555
562
  width,
556
563
  height,
557
- headerHeight: headerLines.length,
558
- footerHeight: footerLines.length,
559
- panelWidth: panelManager.isVisible() && panelManager.getAllOpen().length > 0
560
- ? panelManager.getRightWidth(width)
561
- : 0,
564
+ headerHeight: shellHeaderLines.length,
565
+ footerHeight: shellFooterLines.length,
566
+ panelWidth,
562
567
  });
563
568
  const vHeight = shellLayout.body.height;
564
569
  const conversationWidth = shellLayout.conversation.width;
565
570
  activeConversationWidth = conversationWidth;
566
- const hasPanelWorkspace = panelManager.isVisible() && panelManager.getAllOpen().length > 0;
571
+ const hasPanelWorkspace = !onboardingOwnsScreen && panelManager.isVisible() && panelManager.getAllOpen().length > 0;
567
572
  conversation.setSplashSuppressed(hasPanelWorkspace);
568
573
 
569
574
  // Flush pending renders after updating the width provider and splash posture
@@ -627,27 +632,29 @@ async function main() {
627
632
  });
628
633
 
629
634
  // Panel composite data
630
- const panelComposite = buildPanelCompositeData(
631
- panelManager,
632
- input,
633
- shellLayout.panel?.width ?? 0,
634
- shellLayout.panel?.height ?? vHeight,
635
- );
635
+ const panelComposite = onboardingOwnsScreen
636
+ ? { panelData: undefined, panelWidth: 0 }
637
+ : buildPanelCompositeData(
638
+ panelManager,
639
+ input,
640
+ shellLayout.panel?.width ?? 0,
641
+ shellLayout.panel?.height ?? vHeight,
642
+ );
636
643
 
637
644
  compositor.composite({
638
645
  width, height,
639
- header: headerLines,
646
+ header: shellHeaderLines,
640
647
  viewport,
641
- footer: footerLines,
642
- selection: {
648
+ footer: shellFooterLines,
649
+ selection: onboardingOwnsScreen ? undefined : {
643
650
  isCellSelected: (col, row) => selection.isCellSelected(col, row),
644
651
  scrollTop,
645
652
  lineCount: conversation.history.getLineCount(),
646
653
  },
647
- search: input.searchManager.active ? {
654
+ search: !onboardingOwnsScreen && input.searchManager.active ? {
648
655
  manager: input.searchManager,
649
656
  scrollTop,
650
- viewportStartY: 2,
657
+ viewportStartY: shellHeaderLines.length,
651
658
  } : undefined,
652
659
  panel: panelComposite.panelData,
653
660
  panelWidth: panelComposite.panelWidth,
@@ -675,15 +682,6 @@ async function main() {
675
682
  render,
676
683
  });
677
684
 
678
- applyInitialTuiCliState({
679
- cli,
680
- input,
681
- commandRegistry,
682
- commandContext,
683
- shellPaths: ctx.services.shellPaths,
684
- render,
685
- });
686
-
687
685
  // --- Streaming speed + tool preview wiring ---
688
686
  const refreshGit = () => gitStatusProvider.refresh().then((info) => { lastGitInfoRef.value = info; render(); }).catch(() => { /* non-fatal */ });
689
687
  // Refresh git status after each turn completes or after tool results arrive
@@ -729,6 +727,15 @@ async function main() {
729
727
  stdin.setEncoding('utf8');
730
728
  stdout.write((cli.flags.noAltScreen ? '' : ALT_SCREEN_ENTER) + CLEAR_SCREEN + CURSOR_HIDE + MOUSE_ENABLE + KEYBOARD_EXT_ENABLE + PASTE_ENABLE);
731
729
 
730
+ applyInitialTuiCliState({
731
+ cli,
732
+ input,
733
+ commandRegistry,
734
+ commandContext,
735
+ shellPaths: ctx.services.shellPaths,
736
+ render,
737
+ });
738
+
732
739
  stdin.on('data', (data: string) => {
733
740
  const blocking = handleBlockingShellInput({
734
741
  data,
@@ -86,11 +86,11 @@ export class Compositor {
86
86
  const leftWidth = hasPanel ? Math.max(1, width - panelWidth - 1) : width;
87
87
  const sepX = hasPanel ? leftWidth : -1;
88
88
 
89
- // 1. Draw Header (Rows 0-1) — always full width
89
+ // 1. Draw Header — always full width
90
90
  header.forEach((line, i) => newBuffer.blitLine(i, line));
91
91
 
92
- // 2. Draw Viewport (Starting at Row 2)
93
- const viewportStartY = 2;
92
+ // 2. Draw Viewport directly after the supplied header.
93
+ const viewportStartY = header.length;
94
94
  const vHeight = Math.max(0, height - header.length - footer.length);
95
95
 
96
96
  // Calculate the offset for bottom-anchored short history
@@ -60,6 +60,12 @@ function modeLabel(mode: OnboardingWizardController['mode']): string {
60
60
  return 'New setup';
61
61
  }
62
62
 
63
+ function changedScreensLabel(wizard: OnboardingWizardController): string {
64
+ if (wizard.dirtyStepCount === 0) return 'no changes';
65
+ if (wizard.dirtyStepCount === 1) return '1 changed screen';
66
+ return `${wizard.dirtyStepCount} changed screens`;
67
+ }
68
+
63
69
  function stepGlyph(
64
70
  wizard: OnboardingWizardController,
65
71
  step: OnboardingWizardStepDefinition,
@@ -241,17 +247,17 @@ function renderFieldRow(
241
247
 
242
248
  function footerText(wizard: OnboardingWizardController): string {
243
249
  if (wizard.isEditingTextField()) {
244
- return '[Enter] Save value [Esc] Cancel edit [Backspace] Delete [Type] Edit value';
250
+ return '[Enter] Save value [Esc] Cancel edit [Backspace] Delete char [Del/Ctrl+U] Clear value';
245
251
  }
246
252
 
247
- return '[Enter] Toggle/open selected [Tab] Next screen [Shift+Tab] Previous [↑↓] Move [Esc] Close';
253
+ return '[Enter] Toggle/open [Esc] Close [Tab/Shift+Tab] Screen [↑↓] Move [Del/Ctrl+U] Clear input';
248
254
  }
249
255
 
250
256
  function controlsText(wizard: OnboardingWizardController): string {
251
257
  if (wizard.isEditingTextField()) {
252
- return 'Controls: Enter saves this value, Esc cancels editing, Backspace deletes, typing edits the value.';
258
+ return 'Controls: Enter saves this value, Esc cancels editing, Backspace deletes one character, Delete or Ctrl+U clears the field.';
253
259
  }
254
- return 'Controls: Enter or Space changes the selected row; Tab/Shift+Tab changes screens; arrows move; typing edits selected inputs.';
260
+ return 'Controls: Enter or Space changes the selected row; Delete or Ctrl+U clears selected text inputs; Tab/Shift+Tab changes screens; arrows move.';
255
261
  }
256
262
 
257
263
  function renderWideLayout(
@@ -271,18 +277,18 @@ function renderWideLayout(
271
277
  const summaryBg = UI_TONES.bg.summary;
272
278
  const innerLeft = layout.margin + 1;
273
279
  const availableInner = layout.innerWidth - 2;
274
- const leftWidthBase = layout.innerWidth >= 108 ? 22 : 18;
275
- const rightWidthBase = layout.innerWidth >= 108 ? 30 : 24;
276
- const minCenterWidth = 34;
277
- let leftWidth = Math.min(leftWidthBase, Math.max(16, availableInner - minCenterWidth - 12));
278
- let rightWidth = Math.min(rightWidthBase, Math.max(20, availableInner - minCenterWidth - leftWidth));
280
+ const leftWidthBase = layout.innerWidth >= 150 ? 32 : layout.innerWidth >= 108 ? 28 : 24;
281
+ const rightWidthBase = layout.innerWidth >= 150 ? 34 : layout.innerWidth >= 108 ? 32 : 24;
282
+ const minCenterWidth = layout.innerWidth >= 120 ? 48 : 40;
283
+ let leftWidth = Math.min(leftWidthBase, Math.max(20, availableInner - minCenterWidth - 12));
284
+ let rightWidth = Math.min(rightWidthBase, Math.max(22, availableInner - minCenterWidth - leftWidth));
279
285
  let centerWidth = layout.innerWidth - leftWidth - rightWidth - 2;
280
286
 
281
287
  if (centerWidth < minCenterWidth) {
282
288
  const deficit = minCenterWidth - centerWidth;
283
- const leftCut = Math.min(Math.max(0, leftWidth - 16), Math.ceil(deficit / 2));
289
+ const leftCut = Math.min(Math.max(0, leftWidth - 20), Math.ceil(deficit / 2));
284
290
  leftWidth -= leftCut;
285
- rightWidth -= Math.min(Math.max(0, rightWidth - 20), deficit - leftCut);
291
+ rightWidth -= Math.min(Math.max(0, rightWidth - 22), deficit - leftCut);
286
292
  centerWidth = layout.innerWidth - leftWidth - rightWidth - 2;
287
293
  }
288
294
 
@@ -296,9 +302,9 @@ function renderWideLayout(
296
302
  currentStep.summaryTitle,
297
303
  ...currentStep.summaryLines.slice(0, 2),
298
304
  `Fields ${wizard.getCompletedFieldCount(wizard.stepIndex)}/${wizard.getStepFieldCount(wizard.stepIndex)} complete`,
299
- `Dirty steps ${wizard.dirtyStepCount}`,
305
+ changedScreensLabel(wizard),
300
306
  ];
301
- const fieldStartRow = 5;
307
+ const fieldStartRow = 6;
302
308
  const selectedText = selectedFieldText(wizard);
303
309
  const fieldRows = buildFieldRows(wizard, visibleFields, Math.max(0, bodyRows - fieldStartRow));
304
310
 
@@ -316,7 +322,7 @@ function renderWideLayout(
316
322
  bg: headerBg,
317
323
  bold: true,
318
324
  });
319
- const meta = `${modeLabel(wizard.mode)} ${wizard.stepIndex + 1}/${wizard.steps.length} dirty ${wizard.dirtyStepCount}`;
325
+ const meta = `${modeLabel(wizard.mode)} ${wizard.stepIndex + 1}/${wizard.steps.length} ${changedScreensLabel(wizard)}`;
320
326
  putOverlayText(topLine, Math.max(layout.margin + 2, layout.margin + layout.width - getDisplayWidth(meta) - 3), layout.width - 4, meta, {
321
327
  fg: UI_TONES.fg.secondary,
322
328
  bg: headerBg,
@@ -376,19 +382,24 @@ function renderWideLayout(
376
382
  bg: bodyBg,
377
383
  });
378
384
  } else if (row === 2) {
385
+ putOverlayText(line, centerStart + 1, centerWidth - 2, descriptionLines[1] ?? '', {
386
+ fg: UI_TONES.fg.secondary,
387
+ bg: bodyBg,
388
+ });
389
+ } else if (row === 3) {
379
390
  fillRange(line, centerStart, centerWidth, railBg);
380
391
  putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(controlsText(wizard), centerWidth - 2), {
381
392
  fg: UI_TONES.state.info,
382
393
  bg: railBg,
383
394
  });
384
- } else if (row === 3) {
395
+ } else if (row === 4) {
385
396
  fillRange(line, centerStart, centerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
386
397
  putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(`Focus: ${selectedText.title.replace(/^Selected: /, '')}`, centerWidth - 2), {
387
398
  fg: UI_TONES.fg.primary,
388
399
  bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
389
400
  bold: true,
390
401
  });
391
- } else if (row === 4) {
402
+ } else if (row === 5) {
392
403
  fillRange(line, centerStart, centerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
393
404
  putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(selectedText.hint, centerWidth - 2), {
394
405
  fg: UI_TONES.fg.secondary,
@@ -473,7 +484,7 @@ function renderCollapsedLayout(
473
484
  const innerStart = layout.margin + 1;
474
485
  const innerWidth = layout.innerWidth;
475
486
  const descriptionLines = wrapText(currentStep.description, Math.max(14, innerWidth - 2)).slice(0, 2);
476
- const fieldStartRow = 5;
487
+ const fieldStartRow = 6;
477
488
  const selectedText = selectedFieldText(wizard);
478
489
  const fieldRows = buildFieldRows(wizard, visibleFields, Math.max(0, bodyRows - fieldStartRow));
479
490
 
@@ -491,7 +502,7 @@ function renderCollapsedLayout(
491
502
  bg: headerBg,
492
503
  bold: true,
493
504
  });
494
- const meta = `${wizard.stepIndex + 1}/${wizard.steps.length} • dirty ${wizard.dirtyStepCount}`;
505
+ const meta = `${wizard.stepIndex + 1}/${wizard.steps.length} • ${changedScreensLabel(wizard)}`;
495
506
  putOverlayText(topLine, Math.max(layout.margin + 2, layout.margin + layout.width - getDisplayWidth(meta) - 3), layout.width - 4, meta, {
496
507
  fg: UI_TONES.fg.secondary,
497
508
  bg: headerBg,
@@ -533,19 +544,24 @@ function renderCollapsedLayout(
533
544
  bg: bodyBg,
534
545
  });
535
546
  } else if (row === 2) {
547
+ putOverlayText(line, innerStart + 1, innerWidth - 2, descriptionLines[1] ?? '', {
548
+ fg: UI_TONES.fg.secondary,
549
+ bg: bodyBg,
550
+ });
551
+ } else if (row === 3) {
536
552
  fillRange(line, innerStart, innerWidth, UI_TONES.bg.section);
537
553
  putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(controlsText(wizard), innerWidth - 2), {
538
554
  fg: UI_TONES.state.info,
539
555
  bg: UI_TONES.bg.section,
540
556
  });
541
- } else if (row === 3) {
557
+ } else if (row === 4) {
542
558
  fillRange(line, innerStart, innerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
543
559
  putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(`Focus: ${selectedText.title.replace(/^Selected: /, '')}`, innerWidth - 2), {
544
560
  fg: UI_TONES.fg.primary,
545
561
  bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
546
562
  bold: true,
547
563
  });
548
- } else if (row === 4) {
564
+ } else if (row === 5) {
549
565
  fillRange(line, innerStart, innerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
550
566
  putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(selectedText.hint, innerWidth - 2), {
551
567
  fg: UI_TONES.fg.secondary,
@@ -592,7 +608,8 @@ export function renderOnboardingWizard(
592
608
  width: number,
593
609
  viewportHeight: number,
594
610
  ): Line[] {
595
- const layout = createOverlayBoxLayout(width, 0, width);
611
+ const margin = width >= 64 ? 1 : 0;
612
+ const layout = createOverlayBoxLayout(width, margin, Math.max(20, width - margin * 2));
596
613
  const collapsed = layout.innerWidth < 86;
597
614
  return collapsed
598
615
  ? renderCollapsedLayout(wizard, width, viewportHeight, layout)
@@ -75,6 +75,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
75
75
  permissions: 'Permissions',
76
76
  mcp: 'MCP',
77
77
  sandbox: 'Sandbox',
78
+ surfaces: 'Surfaces',
78
79
  danger: 'Danger',
79
80
  tools: 'Tools',
80
81
  flags: 'Flags',
@@ -129,6 +130,14 @@ export const SETTING_LABELS: Partial<Record<string, string>> = {
129
130
  'web.port': 'Web Port',
130
131
  'web.publicBaseUrl': 'Web Public Base URL',
131
132
  'web.staticAssetsDir': 'Web Static Assets Dir',
133
+ 'surfaces.ntfy.enabled': 'ntfy Enabled',
134
+ 'surfaces.ntfy.baseUrl': 'ntfy Base URL',
135
+ 'surfaces.ntfy.topic': 'ntfy Default Delivery Topic',
136
+ 'surfaces.ntfy.chatTopic': 'ntfy Chat Topic',
137
+ 'surfaces.ntfy.agentTopic': 'ntfy Agent Topic',
138
+ 'surfaces.ntfy.remoteTopic': 'ntfy Daemon-Only Remote Topic',
139
+ 'surfaces.ntfy.token': 'ntfy Token',
140
+ 'surfaces.ntfy.defaultPriority': 'ntfy Default Priority',
132
141
  };
133
142
 
134
143
  export function getSettingLabel(entry: SettingEntry): string {
@@ -64,6 +64,7 @@ export function renderSettingsModal(
64
64
  const isUiTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'ui';
65
65
  const isToolsTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'tools';
66
66
  const isNetworkTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'network';
67
+ const isSurfacesTab = SETTINGS_CATEGORIES[modal.categoryIndex] === 'surfaces';
67
68
  let persistentHelpers: import('./modal-factory.ts').ModalHelperRow[] | undefined;
68
69
  sections.push({
69
70
  type: 'text',
@@ -79,6 +80,8 @@ export function renderSettingsModal(
79
80
  ? 'Feature flags control staged or experimental behavior. Some changes may require restart.'
80
81
  : isToolsTab
81
82
  ? 'Configure tool LLM routing and helper model. Provider and model fields are optional — empty means use the active provider.'
83
+ : isSurfacesTab
84
+ ? 'Configure external app surfaces. Toggle each Enabled setting to auto-start that surface when the service starts.'
82
85
  : isNetworkTab
83
86
  ? 'Configure control-plane and HTTP-listener binding. hostMode local/network use preset hosts; custom enables the host field. Changes trigger auto-restart.'
84
87
  : 'Browse and adjust operator-facing runtime settings by category.',
@@ -11,6 +11,7 @@ import { Compositor } from '../renderer/compositor.ts';
11
11
  import type { PermissionRequestHandler } from '@pellux/goodvibes-sdk/platform/permissions/prompt';
12
12
  import type { SystemMessageRouter } from '../core/system-message-router.ts';
13
13
  import type { ConversationFollowUpItem } from '@pellux/goodvibes-sdk/platform/core/conversation-follow-ups';
14
+ import type { OrchestratorUserInputOptions } from '../core/orchestrator.ts';
14
15
  import type { ControlPlaneRecentEvent } from '@pellux/goodvibes-sdk/platform/control-plane/gateway';
15
16
  import type { MutableRuntimeState } from '@pellux/goodvibes-sdk/platform/runtime/mutable-runtime-state';
16
17
  import type { BootstrapOptions } from './context.ts';
@@ -55,12 +56,32 @@ export interface BootstrapCoreState {
55
56
  * When non-null, COMPANION_MESSAGE_RECEIVED fires a real LLM turn via
56
57
  * orchestrator.handleUserInput() instead of only appending the user message.
57
58
  */
58
- readonly orchestratorHandleUserInputRef: { value: ((text: string) => void) | null };
59
+ readonly orchestratorHandleUserInputRef: { value: ((text: string, options?: OrchestratorUserInputOptions) => void) | null };
59
60
  readonly requestRender: () => void;
60
61
  readonly setRenderRequest: (fn: () => void) => void;
61
62
  readonly runtimeSessionIdRef: { value: string };
62
63
  }
63
64
 
65
+ export type CompanionMessagePayload = Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>;
66
+
67
+ export function companionMessageToOrchestratorInputOptions(
68
+ payload: CompanionMessagePayload,
69
+ ): OrchestratorUserInputOptions {
70
+ const metadata = payload.metadata;
71
+ const surface = typeof metadata?.surface === 'string' ? metadata.surface : undefined;
72
+ const topic = typeof metadata?.topic === 'string' ? metadata.topic : undefined;
73
+
74
+ return {
75
+ origin: {
76
+ source: payload.source,
77
+ messageId: payload.messageId,
78
+ ...(surface ? { surface } : {}),
79
+ ...(topic ? { topic } : {}),
80
+ ...(metadata ? { metadata } : {}),
81
+ },
82
+ };
83
+ }
84
+
64
85
  export async function initializeBootstrapCore(
65
86
  stdout: NodeJS.WriteStream,
66
87
  options: BootstrapOptions,
@@ -362,13 +383,17 @@ export async function initializeBootstrapCore(
362
383
  // The fallback (ref not yet set) adds the message to the conversation view only —
363
384
  // this path is unreachable in practice because the event bus is not connected to
364
385
  // any live HTTP traffic until after the orchestrator is wired in bootstrap.ts.
365
- const orchestratorHandleUserInputRef: { value: ((text: string) => void) | null } = { value: null };
386
+ const orchestratorHandleUserInputRef: {
387
+ value: ((text: string, options?: OrchestratorUserInputOptions) => void) | null;
388
+ } = { value: null };
366
389
  runtimeUnsubs.push(runtimeBus.on<Extract<SessionEvent, { type: 'COMPANION_MESSAGE_RECEIVED' }>>(
367
390
  'COMPANION_MESSAGE_RECEIVED',
368
391
  ({ payload }) => {
369
392
  if (orchestratorHandleUserInputRef.value) {
370
393
  // Delegate to the orchestrator: adds user message + fires a real LLM turn.
371
- orchestratorHandleUserInputRef.value(payload.body);
394
+ // Preserve surface origin metadata so the SDK can correlate replies back
395
+ // to the originating external channel, including ntfy chat topics.
396
+ orchestratorHandleUserInputRef.value(payload.body, companionMessageToOrchestratorInputOptions(payload));
372
397
  } else {
373
398
  // Fallback: render the user message immediately (orchestrator not yet ready).
374
399
  conversation.addUserMessage(payload.body);
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { join } from 'node:path';
13
13
  import net from 'node:net';
14
- import { Orchestrator } from '../core/orchestrator.ts';
14
+ import { Orchestrator, type OrchestratorUserInputOptions } from '../core/orchestrator.ts';
15
15
  import { AcpManager } from '@pellux/goodvibes-sdk/platform/acp/manager';
16
16
  import { getTierPromptSupplement, getTierForContextWindow } from '@pellux/goodvibes-sdk/platform/providers/tier-prompts';
17
17
  import { logger } from '@pellux/goodvibes-sdk/platform/utils/logger';
@@ -195,8 +195,10 @@ export async function bootstrapRuntime(
195
195
  return supplement ? runtime.systemPrompt + '\n\n' + supplement : runtime.systemPrompt;
196
196
  },
197
197
  hookDispatcher,
198
+ flagManager: services.featureFlags,
198
199
  requestRender: () => orchestratorRefs.requestRender(),
199
200
  runtimeBus,
201
+ sessionId: runtime.sessionId,
200
202
  services: {
201
203
  agentManager: services.agentManager,
202
204
  wrfcController: services.wrfcController,
@@ -204,8 +206,8 @@ export async function bootstrapRuntime(
204
206
  });
205
207
  conversationFollowUpRef.value = (item) => orchestrator.enqueueConversationFollowUp(item);
206
208
  // Wire orchestratorHandleUserInputRef so COMPANION_MESSAGE_RECEIVED fires a real LLM turn.
207
- orchestratorHandleUserInputRef.value = (text: string) => {
208
- orchestrator.handleUserInput(text).catch((err: unknown) => {
209
+ orchestratorHandleUserInputRef.value = (text: string, options?: OrchestratorUserInputOptions) => {
210
+ orchestrator.handleUserInput(text, undefined, options).catch((err: unknown) => {
209
211
  logger.debug('companion handleUserInput safety catch', { error: String(err) });
210
212
  });
211
213
  };
@@ -370,6 +372,20 @@ export async function bootstrapRuntime(
370
372
  };
371
373
  };
372
374
 
375
+ const waitForConfigDrivenRestarts = async (handle: ExternalServicesHandle): Promise<void> => {
376
+ const waitForRestart = async (service: unknown): Promise<void> => {
377
+ const maybeRestarting = service as { waitForRestart?: unknown } | null;
378
+ if (typeof maybeRestarting?.waitForRestart === 'function') {
379
+ await maybeRestarting.waitForRestart();
380
+ }
381
+ };
382
+
383
+ await Promise.all([
384
+ waitForRestart(handle.daemonServer),
385
+ waitForRestart(handle.httpListener),
386
+ ]);
387
+ };
388
+
373
389
  let externalServices: ExternalServicesHandle = {
374
390
  daemonServer: null,
375
391
  httpListener: null,
@@ -401,6 +417,7 @@ export async function bootstrapRuntime(
401
417
  // A failed previous startup should not prevent a restart attempt.
402
418
  }
403
419
  }
420
+ await waitForConfigDrivenRestarts(externalServices);
404
421
  await externalServices.stop();
405
422
  const previousBindings = externalServiceBindings;
406
423
  externalServiceBindings = readExternalServiceBindings();
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
2
2
  import { dirname } from 'node:path';
3
3
  import { isSecretRefInput } from '@pellux/goodvibes-sdk/platform/config/secret-refs';
4
4
  import { CONFIG_SCHEMA, DEFAULT_CONFIG } from '../../config/index.ts';
5
+ import type { FeatureFlagConfigKey } from '../surface-feature-flags.ts';
5
6
  import {
6
7
  getOnboardingRuntimeStatePath,
7
8
  readOnboardingRuntimeState,
@@ -133,11 +134,39 @@ function isMalformedGoodVibesSecretReferenceValue(value: string): boolean {
133
134
  return normalized.startsWith('goodvibes://') && !isGoodVibesSecretReferenceValue(normalized);
134
135
  }
135
136
 
137
+ function isFeatureFlagConfigKey(key: string): key is FeatureFlagConfigKey {
138
+ return key === 'featureFlags' || key.startsWith('featureFlags.');
139
+ }
140
+
141
+ function validateFeatureFlagConfigValue(operation: Extract<OnboardingApplyOperation, { kind: 'set-config' }>): boolean {
142
+ if (!isFeatureFlagConfigKey(operation.key)) return false;
143
+
144
+ if (operation.key === 'featureFlags') {
145
+ if (!isPlainObject(operation.value)) throw new Error('featureFlags expects an object value.');
146
+ for (const [flagId, state] of Object.entries(operation.value)) {
147
+ if (flagId.trim().length === 0) throw new Error('featureFlags cannot contain an empty feature id.');
148
+ if (state !== 'enabled' && state !== 'disabled') {
149
+ throw new Error(`featureFlags.${flagId} expects enabled or disabled.`);
150
+ }
151
+ }
152
+ return true;
153
+ }
154
+
155
+ const flagId = operation.key.slice('featureFlags.'.length);
156
+ if (flagId.trim().length === 0) throw new Error('featureFlags requires a feature id.');
157
+ if (operation.value !== 'enabled' && operation.value !== 'disabled') {
158
+ throw new Error(`Config key ${operation.key} expects enabled or disabled.`);
159
+ }
160
+ return true;
161
+ }
162
+
136
163
  function validateConfigValue(operation: Extract<OnboardingApplyOperation, { kind: 'set-config' }>): void {
137
164
  if (typeof operation.value === 'string' && isMalformedGoodVibesSecretReferenceValue(operation.value)) {
138
165
  throw new Error(`Config key ${operation.key} only accepts goodvibes://secrets/... secret references.`);
139
166
  }
140
167
 
168
+ if (validateFeatureFlagConfigValue(operation)) return;
169
+
141
170
  const schema = CONFIG_SCHEMA.find((entry) => entry.key === operation.key);
142
171
  if (!schema) {
143
172
  const defaultValue = operation.key.split('.').reduce<unknown>((cursor, part) => (
@@ -204,9 +233,6 @@ function validateAuthOperation(
204
233
  if (existing && !requiredRoles.every((role) => existing.roles.includes(role))) {
205
234
  throw new Error(`Existing local auth user ${username} is missing required role(s): ${requiredRoles.join(', ')}.`);
206
235
  }
207
- if (existing && operation.retireBootstrapCredential) {
208
- throw new Error('Replacing a bootstrap credential requires a new local admin username.');
209
- }
210
236
  }
211
237
 
212
238
  function validateAcknowledgementOperation(
@@ -242,7 +268,7 @@ function applyConfigOperation(
242
268
  };
243
269
  }
244
270
 
245
- deps.config.setDynamic(operation.key, operation.value);
271
+ deps.config.setDynamic(operation.key as never, operation.value);
246
272
  return {
247
273
  kind: operation.kind,
248
274
  summary: `Updated ${operation.key} in global onboarding settings.`,
@@ -278,7 +304,9 @@ function applyAuthOperation(
278
304
  ? parseBootstrapCredential(readFileSync(before.bootstrapCredentialPath, 'utf-8'))
279
305
  : null;
280
306
 
281
- if (!existing) {
307
+ if (existing) {
308
+ auth.rotatePassword(username, operation.password);
309
+ } else {
282
310
  auth.addUser(username, operation.password, operation.roles ?? ['admin']);
283
311
  }
284
312
 
@@ -296,7 +324,7 @@ function applyAuthOperation(
296
324
  return {
297
325
  kind: operation.kind,
298
326
  summary: existing
299
- ? `Verified local auth user ${username}.`
327
+ ? `Updated local auth user ${username}.`
300
328
  : `Created local auth user ${username}.`,
301
329
  };
302
330
  }
@@ -391,9 +419,9 @@ async function buildRollbackAction(
391
419
  );
392
420
  }
393
421
 
394
- const previous = deps.config.get(operation.key);
422
+ const previous = deps.config.get(operation.key as never);
395
423
  return () => {
396
- deps.config.setDynamic(operation.key, previous);
424
+ deps.config.setDynamic(operation.key as never, previous);
397
425
  };
398
426
  }
399
427
 
@@ -240,22 +240,22 @@ function hasExternalIntegrations(snapshot: OnboardingSnapshotState): boolean {
240
240
 
241
241
  function describeLocalTuiOnly(snapshot: OnboardingSnapshotState): string {
242
242
  if (!hasAnyServerEnabled(snapshot)) {
243
- return 'Keep GoodVibes in this terminal and disable browser access, background services, network listeners, and external surfaces.';
243
+ return 'Use GoodVibes only in this terminal. No browser access, background service, HTTP listener, external app surface, or network setup.';
244
244
  }
245
245
 
246
- return 'Switching to this disables browser access, background services, network listeners, and external surfaces.';
246
+ return 'Turn off browser access, background services, HTTP listeners, external app surfaces, and network setup.';
247
247
  }
248
248
 
249
249
  function describeBrowserAccess(snapshot: OnboardingSnapshotState): string {
250
250
  return snapshot.bindSettings.web.enabled
251
- ? 'Keep the background service and web UI enabled, reachable according to the network step.'
252
- : 'Enable the background service and web UI, reachable on the local network by default unless customized.';
251
+ ? 'Keep the background service and web UI enabled. Network reachability is controlled on the next screen.'
252
+ : 'Run the background service and web UI. GoodVibes will use the local network by default; you can restrict or customize it next.';
253
253
  }
254
254
 
255
255
  function describeRemoteDeviceAccess(snapshot: OnboardingSnapshotState): string {
256
256
  return hasRemoteDeviceAccess(snapshot)
257
- ? 'Keep enabled GoodVibes services reachable from other devices on your LAN. Local auth is required.'
258
- : 'Expose enabled GoodVibes services on your LAN so other devices can reach them. Local auth is required.';
257
+ ? 'Keep enabled GoodVibes services reachable from other devices on your LAN. Local authentication is required.'
258
+ : 'Make enabled GoodVibes services reachable from other devices on your LAN. Local authentication is required.';
259
259
  }
260
260
 
261
261
  function describeWebhookIngress(snapshot: OnboardingSnapshotState): string {
@@ -272,7 +272,7 @@ function describeExternalIntegrations(snapshot: OnboardingSnapshotState): string
272
272
  ]).size;
273
273
 
274
274
  if (integrationCount === 0) {
275
- return 'Show Slack, Discord, Telegram, Teams, Matrix, and other app surfaces so they can be enabled and configured here.';
275
+ return 'Enable setup screens for Slack, Discord, Telegram, Teams, Matrix, and other app surfaces you choose.';
276
276
  }
277
277
 
278
278
  return `Review and configure ${integrationCount} detected external app, service, or surface integration signal(s).`;
@@ -40,6 +40,7 @@ function buildConfigSnapshot(
40
40
  network: config.getCategory('network'),
41
41
  surfaces: config.getCategory('surfaces'),
42
42
  service: config.getCategory('service'),
43
+ featureFlags: config.getCategory('featureFlags'),
43
44
  };
44
45
  }
45
46
 
@@ -1,5 +1,6 @@
1
1
  import type { ConfigManager, ConfigKey, GoodVibesConfig } from '../../config/index.ts';
2
2
  import type { SecretsManager, SecretRecord, SecretStorageReview } from '../../config/secrets.ts';
3
+ import type { FeatureFlagConfigKey } from '../surface-feature-flags.ts';
3
4
  import type { LocalAuthSnapshot, UserAuthManager } from '@pellux/goodvibes-sdk/platform/security/user-auth';
4
5
  import type { ShellPathService } from '@pellux/goodvibes-sdk/platform/runtime/shell-paths';
5
6
  import type {
@@ -45,6 +46,7 @@ export interface OnboardingConfigSnapshot {
45
46
  readonly network: GoodVibesConfig['network'];
46
47
  readonly surfaces: GoodVibesConfig['surfaces'];
47
48
  readonly service: GoodVibesConfig['service'];
49
+ readonly featureFlags: GoodVibesConfig['featureFlags'];
48
50
  }
49
51
 
50
52
  export interface OnboardingProviderRoutingSnapshot {
@@ -235,7 +237,7 @@ export interface OnboardingStepDerivationState {
235
237
  export type OnboardingApplyOperation =
236
238
  | {
237
239
  readonly kind: 'set-config';
238
- readonly key: ConfigKey;
240
+ readonly key: ConfigKey | FeatureFlagConfigKey;
239
241
  readonly value: unknown;
240
242
  readonly scope?: 'global' | 'project';
241
243
  }
@@ -381,6 +383,7 @@ export interface OnboardingApplyDependencies {
381
383
  | 'getUser'
382
384
  | 'inspect'
383
385
  | 'revokeSession'
386
+ | 'rotatePassword'
384
387
  >;
385
388
  readonly shellPaths: OnboardingShellPaths;
386
389
  readonly acknowledgementScope?: OnboardingStateScope;
@@ -43,7 +43,7 @@ function verifyConfigOperation(
43
43
  deps: OnboardingVerificationDependencies,
44
44
  operation: Extract<OnboardingApplyOperation, { kind: 'set-config' }>,
45
45
  ): OnboardingVerificationItem {
46
- const actual = deps.config.get(operation.key);
46
+ const actual = deps.config.get(operation.key as never);
47
47
  const ok = isDeepEqual(actual, operation.value);
48
48
 
49
49
  return {