@pellux/goodvibes-tui 0.19.27 → 0.19.29

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 (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +3 -1
  3. package/docs/foundation-artifacts/operator-contract.json +1 -1
  4. package/package.json +2 -2
  5. package/src/cli/bundle-command.ts +3 -2
  6. package/src/cli/entrypoint.ts +2 -2
  7. package/src/cli/help.ts +1 -1
  8. package/src/cli/status.ts +9 -9
  9. package/src/cli/surface-command.ts +46 -11
  10. package/src/cli/tui-startup.ts +4 -4
  11. package/src/daemon/cli.ts +7 -0
  12. package/src/input/handler-interactions.ts +14 -1
  13. package/src/input/handler-onboarding.ts +161 -118
  14. package/src/input/handler.ts +1 -1
  15. package/src/input/onboarding/handler-onboarding-routes.ts +35 -15
  16. package/src/input/onboarding/onboarding-wizard-apply.ts +35 -25
  17. package/src/input/onboarding/onboarding-wizard-constants.ts +4 -5
  18. package/src/input/onboarding/onboarding-wizard-external-surfaces.ts +93 -5
  19. package/src/input/onboarding/onboarding-wizard-helpers.ts +2 -3
  20. package/src/input/onboarding/onboarding-wizard-rules.ts +40 -8
  21. package/src/input/onboarding/onboarding-wizard-state.ts +19 -8
  22. package/src/input/onboarding/onboarding-wizard-steps.ts +226 -93
  23. package/src/input/onboarding/onboarding-wizard-types.ts +15 -0
  24. package/src/input/onboarding/onboarding-wizard.ts +123 -6
  25. package/src/input/settings-modal-types.ts +2 -1
  26. package/src/input/settings-modal.ts +4 -0
  27. package/src/main.ts +35 -27
  28. package/src/renderer/compositor.ts +3 -3
  29. package/src/renderer/onboarding/onboarding-wizard.ts +141 -57
  30. package/src/renderer/settings-modal-helpers.ts +9 -0
  31. package/src/renderer/settings-modal.ts +3 -0
  32. package/src/runtime/bootstrap.ts +15 -0
  33. package/src/runtime/onboarding/apply.ts +45 -90
  34. package/src/runtime/onboarding/derivation.ts +7 -7
  35. package/src/runtime/onboarding/markers.ts +41 -55
  36. package/src/runtime/onboarding/snapshot.ts +1 -0
  37. package/src/runtime/onboarding/state.ts +6 -6
  38. package/src/runtime/onboarding/types.ts +24 -27
  39. package/src/runtime/onboarding/verify.ts +3 -65
  40. package/src/runtime/surface-feature-flags.ts +67 -0
  41. package/src/version.ts +1 -1
@@ -26,7 +26,6 @@ type RenderedFieldRow =
26
26
  readonly kind: 'field';
27
27
  readonly field: OnboardingWizardFieldDefinition;
28
28
  readonly absoluteIndex: number;
29
- readonly line: 0 | 1;
30
29
  };
31
30
 
32
31
  function clamp(value: number, min: number, max: number): number {
@@ -61,6 +60,12 @@ function modeLabel(mode: OnboardingWizardController['mode']): string {
61
60
  return 'New setup';
62
61
  }
63
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
+
64
69
  function stepGlyph(
65
70
  wizard: OnboardingWizardController,
66
71
  step: OnboardingWizardStepDefinition,
@@ -95,7 +100,11 @@ function fieldBadgeTone(
95
100
  return wizard.getFieldValue(field) ? UI_TONES.state.good : UI_TONES.fg.muted;
96
101
  }
97
102
  if (field.kind === 'radio') return UI_TONES.state.active;
98
- if (field.kind === 'masked') return UI_TONES.state.warn;
103
+ if (field.kind === 'text' || field.kind === 'masked') {
104
+ const missingRequired = wizard.getFieldValueLabel(field) === 'Missing';
105
+ if (missingRequired) return UI_TONES.state.warn;
106
+ if (field.kind === 'masked') return UI_TONES.state.warn;
107
+ }
99
108
  return UI_TONES.fg.secondary;
100
109
  }
101
110
 
@@ -116,8 +125,7 @@ function buildFieldRows(
116
125
 
117
126
  fieldWindow.fields.forEach((field, index) => {
118
127
  const absoluteIndex = fieldWindow.start + index;
119
- rows.push({ kind: 'field', field, absoluteIndex, line: 0 });
120
- rows.push({ kind: 'field', field, absoluteIndex, line: 1 });
128
+ rows.push({ kind: 'field', field, absoluteIndex });
121
129
  });
122
130
 
123
131
  if (fieldWindow.end < fieldWindow.total) {
@@ -155,6 +163,48 @@ function fieldHint(
155
163
  return field.hint;
156
164
  }
157
165
 
166
+ function fieldRowPrefix(
167
+ wizard: OnboardingWizardController,
168
+ field: OnboardingWizardFieldDefinition,
169
+ selected: boolean,
170
+ ): string {
171
+ if (selected) return `${OVERLAY_GLYPHS.selected} `;
172
+ if (wizard.isFieldDirty(field.id)) return '◇ ';
173
+ if (field.kind === 'checklist') return (wizard.getFieldValue(field) as boolean) ? '✓ ' : '□ ';
174
+ if (field.kind === 'acknowledgement') return (wizard.getFieldValue(field) as boolean) ? '✓ ' : '□ ';
175
+ if (field.kind === 'action') return '▶ ';
176
+ if (field.kind === 'radio') return '◉ ';
177
+ return ' ';
178
+ }
179
+
180
+ function selectedFieldText(wizard: OnboardingWizardController): {
181
+ readonly title: string;
182
+ readonly hint: string;
183
+ } {
184
+ if (wizard.isEditingTextField() && wizard.editingFieldId !== null) {
185
+ const editingField = wizard.getFieldById(wizard.editingFieldId);
186
+ if (editingField) {
187
+ return {
188
+ title: `Editing: ${editingField.label}`,
189
+ hint: fieldHint(wizard, editingField, true),
190
+ };
191
+ }
192
+ }
193
+
194
+ const field = wizard.getSelectedField();
195
+ if (!field) {
196
+ return {
197
+ title: 'Selected: none',
198
+ hint: 'No selectable row is active on this screen.',
199
+ };
200
+ }
201
+
202
+ return {
203
+ title: `Selected: ${field.label} [${wizard.getFieldValueLabel(field)}]`,
204
+ hint: fieldHint(wizard, field, true),
205
+ };
206
+ }
207
+
158
208
  function renderFieldRow(
159
209
  line: Line,
160
210
  wizard: OnboardingWizardController,
@@ -178,38 +228,36 @@ function renderFieldRow(
178
228
  const fieldBg = selected ? DEFAULT_OVERLAY_PALETTE.selectedBg : UI_TONES.bg.base;
179
229
  fillRange(line, startX, width, fieldBg);
180
230
 
181
- if (fieldRow.line === 0) {
182
- const badge = truncateDisplay(`[${wizard.getFieldValueLabel(field)}]`, Math.max(8, Math.floor(width * 0.38)));
183
- const badgeWidth = getDisplayWidth(badge);
184
- const prefix = selected ? `${OVERLAY_GLYPHS.selected} ` : wizard.isFieldDirty(field.id) ? '◇ ' : ' ';
185
- const labelWidth = Math.max(0, width - badgeWidth - 4);
186
-
187
- putOverlayText(line, startX + 1, labelWidth, truncateDisplay(`${prefix}${field.label}`, labelWidth), {
188
- fg: UI_TONES.fg.primary,
189
- bg: fieldBg,
190
- bold: selected,
191
- });
192
- putOverlayText(line, startX + width - badgeWidth - 1, badgeWidth, badge, {
193
- fg: fieldBadgeTone(wizard, field),
194
- bg: fieldBg,
195
- bold: selected,
196
- });
197
- return;
198
- }
231
+ const badge = truncateDisplay(`[${wizard.getFieldValueLabel(field)}]`, Math.max(8, Math.floor(width * 0.34)));
232
+ const badgeWidth = getDisplayWidth(badge);
233
+ const labelWidth = Math.max(0, width - badgeWidth - 4);
234
+ const prefix = fieldRowPrefix(wizard, field, selected);
199
235
 
200
- putOverlayText(line, startX + 3, Math.max(0, width - 4), truncateDisplay(fieldHint(wizard, field, selected), Math.max(0, width - 4)), {
201
- fg: selected ? UI_TONES.fg.secondary : UI_TONES.fg.muted,
236
+ putOverlayText(line, startX + 1, labelWidth, truncateDisplay(`${prefix}${field.label}`, labelWidth), {
237
+ fg: UI_TONES.fg.primary,
238
+ bg: fieldBg,
239
+ bold: selected,
240
+ });
241
+ putOverlayText(line, startX + width - badgeWidth - 1, badgeWidth, badge, {
242
+ fg: fieldBadgeTone(wizard, field),
202
243
  bg: fieldBg,
203
- dim: !selected,
244
+ bold: selected,
204
245
  });
205
246
  }
206
247
 
207
248
  function footerText(wizard: OnboardingWizardController): string {
208
249
  if (wizard.isEditingTextField()) {
209
- return '[Enter] Save [Esc] Cancel [Backspace] Delete [Type] Edit value';
250
+ return '[Enter] Save value [Esc] Cancel edit [Backspace] Delete char [Del/Ctrl+U] Clear value';
210
251
  }
211
252
 
212
- return '[Tab/Shift+Tab] Step [↑↓/j/k] Move [Enter/Space] Toggle/Open [1-9] Jump [Esc] Close';
253
+ return '[Enter] Toggle/open [Esc] Close [Tab/Shift+Tab] Screen [↑↓] Move [Del/Ctrl+U] Clear input';
254
+ }
255
+
256
+ function controlsText(wizard: OnboardingWizardController): string {
257
+ if (wizard.isEditingTextField()) {
258
+ return 'Controls: Enter saves this value, Esc cancels editing, Backspace deletes one character, Delete or Ctrl+U clears the field.';
259
+ }
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.';
213
261
  }
214
262
 
215
263
  function renderWideLayout(
@@ -229,18 +277,18 @@ function renderWideLayout(
229
277
  const summaryBg = UI_TONES.bg.summary;
230
278
  const innerLeft = layout.margin + 1;
231
279
  const availableInner = layout.innerWidth - 2;
232
- const leftWidthBase = layout.innerWidth >= 108 ? 22 : 18;
233
- const rightWidthBase = layout.innerWidth >= 108 ? 30 : 24;
234
- const minCenterWidth = 34;
235
- let leftWidth = Math.min(leftWidthBase, Math.max(16, availableInner - minCenterWidth - 12));
236
- 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));
237
285
  let centerWidth = layout.innerWidth - leftWidth - rightWidth - 2;
238
286
 
239
287
  if (centerWidth < minCenterWidth) {
240
288
  const deficit = minCenterWidth - centerWidth;
241
- 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));
242
290
  leftWidth -= leftCut;
243
- rightWidth -= Math.min(Math.max(0, rightWidth - 20), deficit - leftCut);
291
+ rightWidth -= Math.min(Math.max(0, rightWidth - 22), deficit - leftCut);
244
292
  centerWidth = layout.innerWidth - leftWidth - rightWidth - 2;
245
293
  }
246
294
 
@@ -252,12 +300,13 @@ function renderWideLayout(
252
300
  const descriptionLines = wrapText(currentStep.description, Math.max(18, centerWidth - 2)).slice(0, 2);
253
301
  const summaryLines = [
254
302
  currentStep.summaryTitle,
255
- ...currentStep.summaryLines,
303
+ ...currentStep.summaryLines.slice(0, 2),
256
304
  `Fields ${wizard.getCompletedFieldCount(wizard.stepIndex)}/${wizard.getStepFieldCount(wizard.stepIndex)} complete`,
257
- `Dirty steps ${wizard.dirtyStepCount}`,
258
- `Pending picker ${wizard.pendingModelPickerTarget ?? 'none'}`,
305
+ changedScreensLabel(wizard),
259
306
  ];
260
- const fieldRows = buildFieldRows(wizard, visibleFields, Math.max(0, bodyRows - 3));
307
+ const fieldStartRow = 6;
308
+ const selectedText = selectedFieldText(wizard);
309
+ const fieldRows = buildFieldRows(wizard, visibleFields, Math.max(0, bodyRows - fieldStartRow));
261
310
 
262
311
  const topLine = createOverlayFilledBorderLine(
263
312
  width,
@@ -273,7 +322,7 @@ function renderWideLayout(
273
322
  bg: headerBg,
274
323
  bold: true,
275
324
  });
276
- 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)}`;
277
326
  putOverlayText(topLine, Math.max(layout.margin + 2, layout.margin + layout.width - getDisplayWidth(meta) - 3), layout.width - 4, meta, {
278
327
  fg: UI_TONES.fg.secondary,
279
328
  bg: headerBg,
@@ -296,7 +345,7 @@ function renderWideLayout(
296
345
  bg: headerBg,
297
346
  bold: true,
298
347
  });
299
- putOverlayText(headerLine, rightStart + 1, rightWidth - 2, 'Summary Rail', {
348
+ putOverlayText(headerLine, rightStart + 1, rightWidth - 2, 'Summary', {
300
349
  fg: UI_TONES.fg.secondary,
301
350
  bg: summaryBg,
302
351
  bold: true,
@@ -333,28 +382,45 @@ function renderWideLayout(
333
382
  bg: bodyBg,
334
383
  });
335
384
  } else if (row === 2) {
336
- const status = fitDisplay(
337
- `Fields ${wizard.getCompletedFieldCount(wizard.stepIndex)}/${wizard.getStepFieldCount(wizard.stepIndex)} complete • ${modeLabel(wizard.mode)}`,
338
- centerWidth - 2,
339
- );
340
- putOverlayText(line, centerStart + 1, centerWidth - 2, descriptionLines[1] ?? status, {
385
+ putOverlayText(line, centerStart + 1, centerWidth - 2, descriptionLines[1] ?? '', {
341
386
  fg: UI_TONES.fg.secondary,
342
387
  bg: bodyBg,
343
388
  });
389
+ } else if (row === 3) {
390
+ fillRange(line, centerStart, centerWidth, railBg);
391
+ putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(controlsText(wizard), centerWidth - 2), {
392
+ fg: UI_TONES.state.info,
393
+ bg: railBg,
394
+ });
395
+ } else if (row === 4) {
396
+ fillRange(line, centerStart, centerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
397
+ putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(`Focus: ${selectedText.title.replace(/^Selected: /, '')}`, centerWidth - 2), {
398
+ fg: UI_TONES.fg.primary,
399
+ bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
400
+ bold: true,
401
+ });
402
+ } else if (row === 5) {
403
+ fillRange(line, centerStart, centerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
404
+ putOverlayText(line, centerStart + 1, centerWidth - 2, truncateDisplay(selectedText.hint, centerWidth - 2), {
405
+ fg: UI_TONES.fg.secondary,
406
+ bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
407
+ });
344
408
  } else {
345
- renderFieldRow(line, wizard, fieldRows[row - 3] ?? { kind: 'empty' }, centerStart, centerWidth);
409
+ renderFieldRow(line, wizard, fieldRows[row - fieldStartRow] ?? { kind: 'empty' }, centerStart, centerWidth);
346
410
  }
347
411
 
348
412
  const step = wizard.steps[row] ?? null;
349
413
  if (step) {
350
414
  const stepState = stepGlyph(wizard, step, row);
351
415
  const completion = `${wizard.getCompletedFieldCount(row)}/${wizard.getStepFieldCount(row)}`;
352
- putOverlayText(line, leftStart + 1, leftWidth - 2, truncateDisplay(`${stepState.glyph} ${row + 1}. ${step.shortLabel}`, leftWidth - 2), {
416
+ const completionWidth = getDisplayWidth(completion);
417
+ const stepLabelWidth = Math.max(0, leftWidth - completionWidth - 4);
418
+ putOverlayText(line, leftStart + 1, stepLabelWidth, truncateDisplay(`${stepState.glyph} ${row + 1}. ${step.shortLabel}`, stepLabelWidth), {
353
419
  fg: stepState.fg,
354
420
  bg: railBg,
355
421
  bold: row === wizard.stepIndex,
356
422
  });
357
- putOverlayText(line, Math.max(leftStart + 1, leftStart + leftWidth - getDisplayWidth(completion) - 2), leftWidth - 2, completion, {
423
+ putOverlayText(line, Math.max(leftStart + 1, leftStart + leftWidth - completionWidth - 2), completionWidth, completion, {
358
424
  fg: wizard.isStepDirty(row) ? UI_TONES.state.warn : UI_TONES.fg.muted,
359
425
  bg: railBg,
360
426
  });
@@ -418,7 +484,9 @@ function renderCollapsedLayout(
418
484
  const innerStart = layout.margin + 1;
419
485
  const innerWidth = layout.innerWidth;
420
486
  const descriptionLines = wrapText(currentStep.description, Math.max(14, innerWidth - 2)).slice(0, 2);
421
- const fieldRows = buildFieldRows(wizard, visibleFields, Math.max(0, bodyRows - 3));
487
+ const fieldStartRow = 6;
488
+ const selectedText = selectedFieldText(wizard);
489
+ const fieldRows = buildFieldRows(wizard, visibleFields, Math.max(0, bodyRows - fieldStartRow));
422
490
 
423
491
  const topLine = createOverlayFilledBorderLine(
424
492
  width,
@@ -434,7 +502,7 @@ function renderCollapsedLayout(
434
502
  bg: headerBg,
435
503
  bold: true,
436
504
  });
437
- const meta = `${wizard.stepIndex + 1}/${wizard.steps.length} • dirty ${wizard.dirtyStepCount}`;
505
+ const meta = `${wizard.stepIndex + 1}/${wizard.steps.length} • ${changedScreensLabel(wizard)}`;
438
506
  putOverlayText(topLine, Math.max(layout.margin + 2, layout.margin + layout.width - getDisplayWidth(meta) - 3), layout.width - 4, meta, {
439
507
  fg: UI_TONES.fg.secondary,
440
508
  bg: headerBg,
@@ -476,16 +544,31 @@ function renderCollapsedLayout(
476
544
  bg: bodyBg,
477
545
  });
478
546
  } else if (row === 2) {
479
- const compactMeta = fitDisplay(
480
- `${currentStep.summaryTitle} • ${wizard.getCompletedFieldCount(wizard.stepIndex)}/${wizard.getStepFieldCount(wizard.stepIndex)} complete`,
481
- innerWidth - 2,
482
- );
483
- putOverlayText(line, innerStart + 1, innerWidth - 2, descriptionLines[1] ?? compactMeta, {
547
+ putOverlayText(line, innerStart + 1, innerWidth - 2, descriptionLines[1] ?? '', {
484
548
  fg: UI_TONES.fg.secondary,
485
549
  bg: bodyBg,
486
550
  });
551
+ } else if (row === 3) {
552
+ fillRange(line, innerStart, innerWidth, UI_TONES.bg.section);
553
+ putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(controlsText(wizard), innerWidth - 2), {
554
+ fg: UI_TONES.state.info,
555
+ bg: UI_TONES.bg.section,
556
+ });
557
+ } else if (row === 4) {
558
+ fillRange(line, innerStart, innerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
559
+ putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(`Focus: ${selectedText.title.replace(/^Selected: /, '')}`, innerWidth - 2), {
560
+ fg: UI_TONES.fg.primary,
561
+ bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
562
+ bold: true,
563
+ });
564
+ } else if (row === 5) {
565
+ fillRange(line, innerStart, innerWidth, DEFAULT_OVERLAY_PALETTE.selectedBg);
566
+ putOverlayText(line, innerStart + 1, innerWidth - 2, truncateDisplay(selectedText.hint, innerWidth - 2), {
567
+ fg: UI_TONES.fg.secondary,
568
+ bg: DEFAULT_OVERLAY_PALETTE.selectedBg,
569
+ });
487
570
  } else {
488
- renderFieldRow(line, wizard, fieldRows[row - 3] ?? { kind: 'empty' }, innerStart, innerWidth);
571
+ renderFieldRow(line, wizard, fieldRows[row - fieldStartRow] ?? { kind: 'empty' }, innerStart, innerWidth);
489
572
  }
490
573
 
491
574
  lines.push(line);
@@ -525,7 +608,8 @@ export function renderOnboardingWizard(
525
608
  width: number,
526
609
  viewportHeight: number,
527
610
  ): Line[] {
528
- 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));
529
613
  const collapsed = layout.innerWidth < 86;
530
614
  return collapsed
531
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.',
@@ -370,6 +370,20 @@ export async function bootstrapRuntime(
370
370
  };
371
371
  };
372
372
 
373
+ const waitForConfigDrivenRestarts = async (handle: ExternalServicesHandle): Promise<void> => {
374
+ const waitForRestart = async (service: unknown): Promise<void> => {
375
+ const maybeRestarting = service as { waitForRestart?: unknown } | null;
376
+ if (typeof maybeRestarting?.waitForRestart === 'function') {
377
+ await maybeRestarting.waitForRestart();
378
+ }
379
+ };
380
+
381
+ await Promise.all([
382
+ waitForRestart(handle.daemonServer),
383
+ waitForRestart(handle.httpListener),
384
+ ]);
385
+ };
386
+
373
387
  let externalServices: ExternalServicesHandle = {
374
388
  daemonServer: null,
375
389
  httpListener: null,
@@ -401,6 +415,7 @@ export async function bootstrapRuntime(
401
415
  // A failed previous startup should not prevent a restart attempt.
402
416
  }
403
417
  }
418
+ await waitForConfigDrivenRestarts(externalServices);
404
419
  await externalServices.stop();
405
420
  const previousBindings = externalServiceBindings;
406
421
  externalServiceBindings = readExternalServiceBindings();
@@ -2,12 +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 {
6
- clearOnboardingCompletionMarker,
7
- getOnboardingCompletionMarkerPath,
8
- readOnboardingCompletionMarker,
9
- writeOnboardingCompletionMarker,
10
- } from './markers.ts';
5
+ import type { FeatureFlagConfigKey } from '../surface-feature-flags.ts';
11
6
  import {
12
7
  getOnboardingRuntimeStatePath,
13
8
  readOnboardingRuntimeState,
@@ -27,11 +22,6 @@ function getNow(deps: Pick<OnboardingApplyDependencies, 'clock'>): number {
27
22
  return deps.clock?.() ?? Date.now();
28
23
  }
29
24
 
30
- function normalizeCompletionSource(source: string): 'wizard' | 'command' | 'import' | 'unknown' {
31
- if (source === 'wizard' || source === 'command' || source === 'import') return source;
32
- return 'unknown';
33
- }
34
-
35
25
  function isPlainObject(value: unknown): value is Record<string, unknown> {
36
26
  return typeof value === 'object' && value !== null && !Array.isArray(value);
37
27
  }
@@ -144,11 +134,39 @@ function isMalformedGoodVibesSecretReferenceValue(value: string): boolean {
144
134
  return normalized.startsWith('goodvibes://') && !isGoodVibesSecretReferenceValue(normalized);
145
135
  }
146
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
+
147
163
  function validateConfigValue(operation: Extract<OnboardingApplyOperation, { kind: 'set-config' }>): void {
148
164
  if (typeof operation.value === 'string' && isMalformedGoodVibesSecretReferenceValue(operation.value)) {
149
165
  throw new Error(`Config key ${operation.key} only accepts goodvibes://secrets/... secret references.`);
150
166
  }
151
167
 
168
+ if (validateFeatureFlagConfigValue(operation)) return;
169
+
152
170
  const schema = CONFIG_SCHEMA.find((entry) => entry.key === operation.key);
153
171
  if (!schema) {
154
172
  const defaultValue = operation.key.split('.').reduce<unknown>((cursor, part) => (
@@ -215,9 +233,6 @@ function validateAuthOperation(
215
233
  if (existing && !requiredRoles.every((role) => existing.roles.includes(role))) {
216
234
  throw new Error(`Existing local auth user ${username} is missing required role(s): ${requiredRoles.join(', ')}.`);
217
235
  }
218
- if (existing && operation.retireBootstrapCredential) {
219
- throw new Error('Replacing a bootstrap credential requires a new local admin username.');
220
- }
221
236
  }
222
237
 
223
238
  function validateAcknowledgementOperation(
@@ -234,17 +249,6 @@ function validateAcknowledgementOperation(
234
249
  }
235
250
  }
236
251
 
237
- function validateCompletionMarkerOperation(
238
- deps: OnboardingApplyDependencies,
239
- operation: Extract<OnboardingApplyOperation, { kind: 'set-completion-marker' }>,
240
- ): void {
241
- const marker = readOnboardingCompletionMarker(deps.shellPaths, operation.scope);
242
- if (marker.parseError && !operation.completed) return;
243
- if (marker.parseError) {
244
- throw new Error(`Existing ${operation.scope} onboarding marker could not be parsed: ${marker.parseError}`);
245
- }
246
- }
247
-
248
252
  function applyConfigOperation(
249
253
  deps: OnboardingApplyDependencies,
250
254
  operation: Extract<OnboardingApplyOperation, { kind: 'set-config' }>,
@@ -264,7 +268,7 @@ function applyConfigOperation(
264
268
  };
265
269
  }
266
270
 
267
- deps.config.setDynamic(operation.key, operation.value);
271
+ deps.config.setDynamic(operation.key as never, operation.value);
268
272
  return {
269
273
  kind: operation.kind,
270
274
  summary: `Updated ${operation.key} in global onboarding settings.`,
@@ -300,7 +304,9 @@ function applyAuthOperation(
300
304
  ? parseBootstrapCredential(readFileSync(before.bootstrapCredentialPath, 'utf-8'))
301
305
  : null;
302
306
 
303
- if (!existing) {
307
+ if (existing) {
308
+ auth.rotatePassword(username, operation.password);
309
+ } else {
304
310
  auth.addUser(username, operation.password, operation.roles ?? ['admin']);
305
311
  }
306
312
 
@@ -318,7 +324,7 @@ function applyAuthOperation(
318
324
  return {
319
325
  kind: operation.kind,
320
326
  summary: existing
321
- ? `Verified local auth user ${username}.`
327
+ ? `Updated local auth user ${username}.`
322
328
  : `Created local auth user ${username}.`,
323
329
  };
324
330
  }
@@ -413,9 +419,9 @@ async function buildRollbackAction(
413
419
  );
414
420
  }
415
421
 
416
- const previous = deps.config.get(operation.key);
422
+ const previous = deps.config.get(operation.key as never);
417
423
  return () => {
418
- deps.config.setDynamic(operation.key, previous);
424
+ deps.config.setDynamic(operation.key as never, previous);
419
425
  };
420
426
  }
421
427
 
@@ -433,9 +439,8 @@ async function buildRollbackAction(
433
439
  );
434
440
  }
435
441
 
436
- return snapshotFileRollback(
437
- getOnboardingCompletionMarkerPath(deps.shellPaths, operation.scope),
438
- );
442
+ const neverOperation: never = operation;
443
+ throw new Error(`Unsupported onboarding operation: ${JSON.stringify(neverOperation)}`);
439
444
  }
440
445
 
441
446
  function applyAcknowledgementOperation(
@@ -458,35 +463,6 @@ function applyAcknowledgementOperation(
458
463
  };
459
464
  }
460
465
 
461
- function applyCompletionMarkerOperation(
462
- deps: OnboardingApplyDependencies,
463
- request: OnboardingApplyRequest,
464
- operation: Extract<OnboardingApplyOperation, { kind: 'set-completion-marker' }>,
465
- ): OnboardingAppliedOperation {
466
- if (!operation.completed) {
467
- clearOnboardingCompletionMarker(deps.shellPaths, operation.scope);
468
- return {
469
- kind: operation.kind,
470
- summary: `Cleared ${operation.scope} onboarding completion marker.`,
471
- };
472
- }
473
-
474
- const updatedAt = getNow(deps);
475
- writeOnboardingCompletionMarker(deps.shellPaths, {
476
- scope: operation.scope,
477
- completedAt: operation.payload?.completedAt,
478
- updatedAt: operation.payload?.updatedAt ?? updatedAt,
479
- source: operation.payload?.source ?? normalizeCompletionSource(request.source),
480
- mode: operation.payload?.mode ?? request.mode,
481
- workspaceRoot: operation.payload?.workspaceRoot,
482
- });
483
-
484
- return {
485
- kind: operation.kind,
486
- summary: `Wrote ${operation.scope} onboarding completion marker.`,
487
- };
488
- }
489
-
490
466
  function orderApplyOperations(
491
467
  operations: readonly OnboardingApplyOperation[],
492
468
  ): readonly OnboardingApplyOperation[] {
@@ -499,7 +475,7 @@ function orderApplyOperations(
499
475
  operation.kind === 'set-config' && operation.key !== 'storage.secretPolicy'
500
476
  ));
501
477
  const finalOperations = operations.filter((operation) => (
502
- operation.kind === 'acknowledge' || operation.kind === 'set-completion-marker'
478
+ operation.kind === 'acknowledge'
503
479
  ));
504
480
 
505
481
  return [
@@ -543,7 +519,8 @@ function prevalidateApplyRequest(
543
519
  continue;
544
520
  }
545
521
 
546
- validateCompletionMarkerOperation(deps, operation);
522
+ const neverOperation: never = operation;
523
+ throw new Error(`Unsupported onboarding operation: ${JSON.stringify(neverOperation)}`);
547
524
  } catch (error) {
548
525
  errors.push({
549
526
  kind: operation.kind,
@@ -560,22 +537,9 @@ function getVerificationFailureKind(itemId: string): OnboardingApplyOperation['k
560
537
  if (itemId.startsWith('secret:')) return 'set-secret';
561
538
  if (itemId.startsWith('auth:')) return 'ensure-auth-user';
562
539
  if (itemId.startsWith('acknowledge:')) return 'acknowledge';
563
- if (itemId.startsWith('marker:')) return 'set-completion-marker';
564
540
  return 'set-config';
565
541
  }
566
542
 
567
- function splitCompletionMarkerOperations(
568
- operations: readonly OnboardingApplyOperation[],
569
- ): {
570
- readonly preMarkerOperations: readonly OnboardingApplyOperation[];
571
- readonly markerOperations: readonly OnboardingApplyOperation[];
572
- } {
573
- return {
574
- preMarkerOperations: operations.filter((operation) => operation.kind !== 'set-completion-marker'),
575
- markerOperations: operations.filter((operation) => operation.kind === 'set-completion-marker'),
576
- };
577
- }
578
-
579
543
  export async function applyOnboardingRequest(
580
544
  deps: OnboardingApplyDependencies,
581
545
  request: OnboardingApplyRequest,
@@ -593,7 +557,6 @@ export async function applyOnboardingRequest(
593
557
  }
594
558
 
595
559
  const orderedOperations = orderApplyOperations(request.operations);
596
- const { preMarkerOperations, markerOperations } = splitCompletionMarkerOperations(orderedOperations);
597
560
  const rollbacks: RollbackAction[] = [];
598
561
 
599
562
  const applyOperations = async (operations: readonly OnboardingApplyOperation[]): Promise<boolean> => {
@@ -625,8 +588,8 @@ export async function applyOnboardingRequest(
625
588
  continue;
626
589
  }
627
590
 
628
- applied.push(applyCompletionMarkerOperation(deps, request, operation));
629
- rollbacks.push(rollback);
591
+ const neverOperation: never = operation;
592
+ throw new Error(`Unsupported onboarding operation: ${JSON.stringify(neverOperation)}`);
630
593
  } catch (error) {
631
594
  const rollbackErrors = await runRollbacks([...rollbacks, rollback]);
632
595
  applied.length = 0;
@@ -660,19 +623,11 @@ export async function applyOnboardingRequest(
660
623
  return false;
661
624
  };
662
625
 
663
- if (!await applyOperations(preMarkerOperations)) {
664
- return { ok: false, applied, skipped, errors };
665
- }
666
-
667
- if (!await verifyOrRollback(preMarkerOperations)) {
668
- return { ok: false, applied, skipped, errors };
669
- }
670
-
671
- if (!await applyOperations(markerOperations)) {
626
+ if (!await applyOperations(orderedOperations)) {
672
627
  return { ok: false, applied, skipped, errors };
673
628
  }
674
629
 
675
- if (!await verifyOrRollback(request.operations)) {
630
+ if (!await verifyOrRollback(orderedOperations)) {
676
631
  return { ok: false, applied, skipped, errors };
677
632
  }
678
633