@pellux/goodvibes-agent 1.9.1 → 1.10.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 (65) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +12 -1
  3. package/dist/package/main.js +36712 -28803
  4. package/dist/package/{web-tree-sitter-jbz042ba.wasm → web-tree-sitter-e011xaqr.wasm} +0 -0
  5. package/docs/README.md +1 -1
  6. package/docs/connected-host.md +1 -1
  7. package/docs/getting-started.md +1 -1
  8. package/docs/tools-and-commands.md +1 -0
  9. package/package.json +3 -3
  10. package/release/live-verification/live-verification.json +11 -11
  11. package/release/live-verification/live-verification.md +12 -12
  12. package/src/agent/email/email-service.ts +1 -1
  13. package/src/agent/email/imap-client.ts +4 -4
  14. package/src/agent/email/smtp-client.ts +5 -5
  15. package/src/cli/config-overrides.ts +29 -14
  16. package/src/cli/entrypoint.ts +12 -4
  17. package/src/cli/launch-auto-update.ts +218 -0
  18. package/src/cli/relay-command.ts +4 -4
  19. package/src/cli/service-posture.ts +2 -2
  20. package/src/cli/tui-startup.ts +31 -0
  21. package/src/cli/workspaces-command.ts +5 -1
  22. package/src/cli-flags.ts +1 -1
  23. package/src/config/index.ts +1 -1
  24. package/src/config/update-settings.ts +45 -0
  25. package/src/config/workspace-registration.ts +214 -15
  26. package/src/input/commands/runtime-services.ts +0 -5
  27. package/src/input/commands/update-runtime.ts +313 -0
  28. package/src/input/commands.ts +2 -0
  29. package/src/input/feed-context-factory.ts +2 -2
  30. package/src/input/handler-feed.ts +8 -8
  31. package/src/input/handler.ts +1 -1
  32. package/src/input/mcp-workspace.ts +5 -1
  33. package/src/input/panel-paste-flood-guard.ts +1 -1
  34. package/src/input/settings-modal-types.ts +11 -5
  35. package/src/input/settings-modal.ts +56 -61
  36. package/src/main.ts +19 -20
  37. package/src/renderer/activity-sidebar.ts +53 -4
  38. package/src/renderer/settings-modal-helpers.ts +2 -0
  39. package/src/renderer/settings-modal.ts +37 -25
  40. package/src/runtime/bootstrap-core.ts +16 -5
  41. package/src/runtime/bootstrap-external-services.ts +107 -11
  42. package/src/runtime/bootstrap-hook-bridge.ts +2 -2
  43. package/src/runtime/bootstrap-shell.ts +4 -4
  44. package/src/runtime/bootstrap.ts +34 -12
  45. package/src/runtime/connected-host-autostart.ts +269 -0
  46. package/src/runtime/daemon-receipts.ts +66 -0
  47. package/src/runtime/diagnostics/panels/index.ts +0 -2
  48. package/src/runtime/feature-enablement.ts +176 -0
  49. package/src/runtime/index.ts +12 -29
  50. package/src/runtime/memory-spine-adoption.ts +18 -0
  51. package/src/runtime/onboarding/apply.ts +50 -37
  52. package/src/runtime/onboarding/types.ts +2 -2
  53. package/src/runtime/onboarding/verify.ts +22 -4
  54. package/src/runtime/release-artifacts.ts +113 -0
  55. package/src/runtime/services.ts +228 -19
  56. package/src/runtime/session-spine-rest-transport.ts +84 -4
  57. package/src/runtime/ui-services.ts +1 -1
  58. package/src/runtime/update-check.ts +64 -0
  59. package/src/shell/ui-openers.ts +8 -0
  60. package/src/tools/agent-harness-metadata.ts +8 -0
  61. package/src/tools/agent-harness-setup-connected-host.ts +1 -1
  62. package/src/tools/agent-harness-setup-posture.ts +1 -1
  63. package/src/version.ts +1 -1
  64. package/src/runtime/diagnostics/panels/ops.ts +0 -156
  65. package/src/runtime/surface-feature-flags.ts +0 -100
@@ -1,6 +1,6 @@
1
1
  /** SettingsModal state for the /settings and /config fullscreen workspace. */
2
2
 
3
- import { CONFIG_SCHEMA, type ConfigKey, type PersistedFlagState } from '@pellux/goodvibes-sdk/platform/config';
3
+ import { CONFIG_SCHEMA, type ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
4
4
  import type { ModelPickerTarget } from './model-picker.ts';
5
5
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
6
6
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
@@ -24,7 +24,9 @@ import {
24
24
  THEME_MODE_SYNTHETIC_SETTING,
25
25
  } from '../renderer/theme-mode-config.ts';
26
26
  import type { FeatureFlagManager } from '@/runtime/index.ts';
27
- import type { FeatureFlag, FlagState } from '@/runtime/index.ts';
27
+ import type { FlagState } from '@/runtime/index.ts';
28
+ import { FEATURE_SETTINGS } from '@/runtime/index.ts';
29
+ import { isFeatureEnabledInConfig, resolveFeatureEnablementWrite } from '../runtime/feature-enablement.ts';
28
30
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
29
31
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
30
32
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
@@ -98,7 +100,7 @@ export class SettingsModal {
98
100
  /** Settings grouped by category. */
99
101
  public groups: Map<SettingsCategory, SettingEntry[]> = new Map();
100
102
 
101
- /** Feature flag entries (populated when flags tab is active). */
103
+ /** Feature entries grouped by settings domain (populated when the features tab is active). */
102
104
  public flagEntries: FlagEntry[] = [];
103
105
  /** MCP server trust entries (populated when mcp tab is active). */
104
106
  public mcpEntries: McpEntry[] = [];
@@ -393,7 +395,7 @@ export class SettingsModal {
393
395
 
394
396
  if (this.currentCategory === 'flags') {
395
397
  const flagEntry = this.getSelectedFlag();
396
- if (!flagEntry || flagEntry.state === 'killed' || !this.featureFlagManager || !this.configManager) return;
398
+ if (!flagEntry || flagEntry.state === 'killed' || !this.configManager) return;
397
399
  const targetState: FlagState = direction === 'right' ? 'enabled' : 'disabled';
398
400
  if (flagEntry.state !== targetState) this._setSelectedFlagState(flagEntry, targetState);
399
401
  return;
@@ -448,47 +450,41 @@ export class SettingsModal {
448
450
  }
449
451
 
450
452
  /**
451
- * Toggle the currently selected feature flag.
453
+ * Toggle the currently selected feature by writing its enablement domain
454
+ * settings key (there is no separate enablement namespace). The live
455
+ * settings bridge propagates the change into the gate manager; features
456
+ * that require a restart record an honest pending-restart state instead.
452
457
  *
453
- * Killed flags cannot be toggled. Non-runtimeToggleable flags are saved as
454
- * overrides for the next Agent launch or owning-host reload; runtimeToggleable
455
- * flags toggle immediately.
458
+ * Killed features cannot be toggled. Always-available features (constant
459
+ * enablement) refuse with guidance naming their real settings keys.
456
460
  */
457
461
  toggleSelectedFlag(): void {
458
462
  const flagEntry = this.getSelectedFlag();
459
- if (!flagEntry || !this.featureFlagManager || !this.configManager) return;
463
+ if (!flagEntry || !this.configManager) return;
460
464
 
461
- const { flag, state } = flagEntry;
465
+ // Killed features are blocked
466
+ if (flagEntry.state === 'killed') return;
462
467
 
463
- // Killed flags are blocked
464
- if (state === 'killed') return;
465
-
466
- const newState: FlagState = state === 'enabled' ? 'disabled' : 'enabled';
468
+ const newState: FlagState = flagEntry.state === 'enabled' ? 'disabled' : 'enabled';
467
469
 
468
470
  this._setSelectedFlagState(flagEntry, newState);
469
471
  }
470
472
 
471
473
  private _setSelectedFlagState(flagEntry: FlagEntry, newState: FlagState): void {
472
- if (!this.featureFlagManager || !this.configManager) return;
473
- const { flag } = flagEntry;
474
+ if (!this.configManager) return;
475
+ if (newState === 'killed') return;
476
+ const feature = flagEntry.feature;
474
477
 
475
- if (!flag.runtimeToggleable) {
476
- // Persist to config only; the flag owner applies it on the next run.
477
- this._persistFlagState(flag.id, newState, flag.defaultState as FlagState);
478
- flagEntry.state = newState;
479
- } else {
480
- // Toggle immediately in manager
481
- try {
482
- if (newState === 'enabled') {
483
- this.featureFlagManager.enable(flag.id);
484
- } else {
485
- this.featureFlagManager.disable(flag.id);
486
- }
487
- this._persistFlagState(flag.id, newState, flag.defaultState as FlagState);
488
- flagEntry.state = newState;
489
- } catch (e) {
490
- logger.error('SettingsModal: failed to toggle feature flag', { flag: flag.id, error: summarizeError(e) });
491
- }
478
+ try {
479
+ const write = resolveFeatureEnablementWrite(feature.id, newState === 'enabled' ? 'enabled' : 'disabled');
480
+ this.configManager.setDynamic(write.key, write.value);
481
+ this._loadFlagEntries();
482
+ this.lastSettingEffectMessage = feature.restartRequired
483
+ ? `${write.key} = ${String(write.value)} saved; takes effect on the next launch.`
484
+ : `${write.key} = ${String(write.value)}`;
485
+ } catch (e) {
486
+ logger.error('SettingsModal: failed to set feature enablement', { feature: feature.id, error: summarizeError(e) });
487
+ this.lastSettingEffectMessage = `Save failed: ${summarizeError(e)}`;
492
488
  }
493
489
  }
494
490
 
@@ -666,16 +662,37 @@ export class SettingsModal {
666
662
  }
667
663
  }
668
664
 
669
- /** Load or refresh the flags tab entries from the feature flag manager. */
665
+ /**
666
+ * Load or refresh the features tab from FEATURE_SETTINGS, grouped by
667
+ * settings domain. State prefers the live gate manager (kill-switch and
668
+ * pending-restart aware); without a manager it derives from the feature's
669
+ * bound domain settings key.
670
+ */
670
671
  private _loadFlagEntries(): void {
671
- if (!this.featureFlagManager) {
672
+ if (!this.configManager) {
672
673
  this.flagEntries = [];
673
674
  return;
674
675
  }
675
- this.flagEntries = Array.from(this.featureFlagManager.getAll().values()).map(({ flag, state }) => ({
676
- flag,
677
- state,
678
- }));
676
+ const configManager = this.configManager;
677
+ const managerStates = this.featureFlagManager?.getAll() ?? null;
678
+ this.flagEntries = FEATURE_SETTINGS
679
+ .map((feature, declarationIndex) => {
680
+ const managed = managerStates?.get(feature.id);
681
+ const derivedState: FlagState = isFeatureEnabledInConfig(configManager, feature.id) ? 'enabled' : 'disabled';
682
+ return {
683
+ entry: {
684
+ feature,
685
+ state: managed?.state ?? derivedState,
686
+ enablementValue: String(configManager.get(feature.enablement.key)),
687
+ },
688
+ declarationIndex,
689
+ };
690
+ })
691
+ .sort((left, right) => (
692
+ left.entry.feature.domain.localeCompare(right.entry.feature.domain)
693
+ || left.declarationIndex - right.declarationIndex
694
+ ))
695
+ .map(({ entry }) => entry);
679
696
  }
680
697
 
681
698
  private _loadMcpEntries(): void {
@@ -697,28 +714,6 @@ export class SettingsModal {
697
714
  this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
698
715
  }
699
716
 
700
- /**
701
- * Persist a flag state override to config.
702
- * Deletes the entry when reverting to defaultState. Skips killed state.
703
- */
704
- private _persistFlagState(flagId: string, newState: FlagState, defaultState: FlagState): void {
705
- if (!this.configManager) return;
706
- if (newState === 'killed') return; // never persist killed state
707
-
708
- try {
709
- const current = (this.configManager.getCategory('featureFlags') as Record<string, PersistedFlagState>) ?? {};
710
- if (newState === defaultState) {
711
- // Revert to default — remove override
712
- delete current[flagId];
713
- } else {
714
- current[flagId] = newState;
715
- }
716
- this.configManager.mergeCategory('featureFlags', current);
717
- } catch (e) {
718
- logger.error('SettingsModal: failed to persist flag state', { flagId, error: summarizeError(e) });
719
- }
720
- }
721
-
722
717
  /** Returns [] for the flags category (flags use flagEntries instead). */
723
718
  private _currentItems(): SettingEntry[] {
724
719
  if (this.currentCategory === 'flags' || this.currentCategory === 'mcp' || this.currentCategory === 'subscriptions') return [];
package/src/main.ts CHANGED
@@ -28,7 +28,7 @@ import { createShellLayout } from './renderer/layout-engine.ts';
28
28
  import { buildShellFooter, estimateShellFooterHeight } from './renderer/shell-surface.ts';
29
29
  import { buildConversationViewport } from './renderer/conversation-layout.ts';
30
30
  import { applyConversationOverlays } from './renderer/conversation-overlays.ts';
31
- import { buildActivitySidebarLines, resolveActivitySidebarWidth } from './renderer/activity-sidebar.ts';
31
+ import { buildActivitySidebarLines, buildSidebarAgentRows, resolveActivitySidebarWidth } from './renderer/activity-sidebar.ts';
32
32
  import { logger } from '@pellux/goodvibes-sdk/platform/utils';
33
33
  import { bootstrapRuntime } from './runtime/bootstrap.ts';
34
34
  import type { BootstrapContext } from './runtime/bootstrap.ts';
@@ -52,7 +52,8 @@ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
52
52
  import { installFocusModeExitGuard, markFocusModeEnabled, wrapRequestPermissionWithApprovalAlert } from './shell/terminal-focus-mode.ts';
53
53
  import { CLEAR_VIEWPORT_HOME, buildEnterSequence, buildExitSequence } from './renderer/terminal-escapes.ts';
54
54
  import { prepareShellCliRuntime } from './cli/entrypoint.ts';
55
- import { applyInitialTuiCliState, formatFatalStartupErrorForLog, formatFatalStartupErrorForUser, getInteractiveTerminalLaunchError } from './cli/tui-startup.ts';
55
+ import { selfUpdateAtLaunch } from './cli/launch-auto-update.ts';
56
+ import { applyInitialTuiCliState, getInteractiveTerminalLaunchError, reportFatalStartupError } from './cli/tui-startup.ts';
56
57
  import { wireSpokenTurnRuntime } from './audio/spoken-turn-wiring.ts';
57
58
  import { createUnhandledRejectionHandler } from './runtime/unhandled-rejection-guard.ts';
58
59
  import { attachSpokenTurnModelRouting, createSpokenTurnInputOptions } from './audio/spoken-turn-model-routing.ts';
@@ -84,6 +85,10 @@ async function main() {
84
85
  process.exit(2);
85
86
  }
86
87
 
88
+ // Launch-time self-update, before any bootstrap or terminal mode change; on
89
+ // an installed update this restarts onto the swapped binary and never returns.
90
+ const launchUpdateLines = await selfUpdateAtLaunch({ configManager, stdout });
91
+
87
92
  const ctx: BootstrapContext = await bootstrapRuntime(stdout, {
88
93
  configManager,
89
94
  workingDir: bootstrapWorkingDir,
@@ -126,6 +131,10 @@ async function main() {
126
131
  });
127
132
  let activeConversationWidth = getTerminalSize(stdout).width;
128
133
  conversation.setWidthProvider(() => activeConversationWidth);
134
+
135
+ // Re-surface pre-bootstrap launch-update lines in-session (the alternate
136
+ // screen wipes the stdout copies written before the renderer existed).
137
+ for (const line of launchUpdateLines) systemMessageRouter.high(`[Update] ${line}`);
129
138
  {
130
139
  const hitlMode = configManager.get('behavior.hitlMode') as HITLMode | undefined;
131
140
  if (hitlMode && (hitlMode === 'quiet' || hitlMode === 'balanced' || hitlMode === 'operator')) {
@@ -622,17 +631,15 @@ async function main() {
622
631
  contextWindow: currentModel.contextWindow,
623
632
  });
624
633
 
625
- // Activity sidebar (ambient status on wide terminals)
634
+ // Activity sidebar (ambient status on wide terminals). Agent rows carry
635
+ // the fleet read-model's headline and stall tell (see activity-sidebar.ts).
626
636
  const sidebar = sidebarWidth > 0
627
637
  ? {
628
638
  lines: buildActivitySidebarLines({
629
639
  now: {
630
640
  busy: orchestrator.isThinking,
631
641
  label: sessionSnapshot.streamToolPreview?.trim() || undefined,
632
- agents: activeAgents.slice(0, 3).map((agent) => ({
633
- label: agent.label,
634
- progress: agent.latestProgress?.trim() || undefined,
635
- })),
642
+ agents: buildSidebarAgentRows(activeAgents, ctx.services.processRegistry.query().nodes),
636
643
  processes: runningProcessCount,
637
644
  },
638
645
  needsYou: pendingPermission
@@ -781,20 +788,12 @@ async function main() {
781
788
  }));
782
789
  }
783
790
  main().catch((err: unknown) => {
784
- const detail = formatFatalStartupErrorForLog(err);
785
- try {
786
- logger.error('Fatal error', { error: detail });
787
- } catch {
788
- // Startup diagnostics must never hide the original launch failure.
789
- }
790
- const userDetail = formatFatalStartupErrorForUser(err, {
791
+ reportFatalStartupError(err, {
791
792
  binary: 'goodvibes-agent',
792
793
  debug: process.env['GOODVIBES_AGENT_DEBUG'] === '1',
794
+ }, {
795
+ logError: (message, context) => logger.error(message, context),
796
+ writeStderr: (chunk) => process.stderr.write(chunk),
797
+ exit: (code) => process.exit(code),
793
798
  });
794
- try {
795
- process.stderr.write(`goodvibes-agent failed to launch:\n${userDetail}\n`);
796
- } catch {
797
- // Ignore secondary stderr failures during process teardown.
798
- }
799
- process.exit(1);
800
799
  });
@@ -28,12 +28,57 @@ export interface ActivitySidebarNow {
28
28
  readonly busy: boolean;
29
29
  /** Short human label for the current work, e.g. "Searching the web…". */
30
30
  readonly label?: string;
31
- /** Background agents with their latest progress lines. */
32
- readonly agents: ReadonlyArray<{ readonly label: string; readonly progress?: string }>;
31
+ /**
32
+ * Background agents with their latest progress lines. `headline` is the
33
+ * fleet read-model's per-node headline (derived from task/phase identity
34
+ * only, replaced in place — never a feed) and wins over the raw progress
35
+ * line when present; `quietForMs` is the fleet stall tell (pure timestamp
36
+ * comparison), rendered as a quiet-duration marker.
37
+ */
38
+ readonly agents: ReadonlyArray<{
39
+ readonly label: string;
40
+ readonly progress?: string;
41
+ readonly headline?: string;
42
+ readonly quietForMs?: number;
43
+ }>;
33
44
  /** Count of running background processes. */
34
45
  readonly processes: number;
35
46
  }
36
47
 
48
+ /**
49
+ * Join active agents with the fleet read-model's nodes (matched by id) into
50
+ * sidebar rows: the per-node headline (task/phase identity only — replaced
51
+ * in place, never a feed) wins over the raw progress line, and the stall
52
+ * tell renders as a quiet-duration marker.
53
+ */
54
+ export function buildSidebarAgentRows(
55
+ activeAgents: ReadonlyArray<{ readonly id: string; readonly label: string; readonly latestProgress?: string | undefined }>,
56
+ fleetNodes: ReadonlyArray<{
57
+ readonly id: string;
58
+ readonly headline?: { readonly text: string } | undefined;
59
+ readonly stall?: { readonly quietForMs: number } | undefined;
60
+ }>,
61
+ ): ActivitySidebarNow['agents'] {
62
+ const nodesById = new Map(fleetNodes.map((node) => [node.id, node]));
63
+ return activeAgents.slice(0, 3).map((agent) => {
64
+ const node = nodesById.get(agent.id);
65
+ return {
66
+ label: agent.label,
67
+ progress: agent.latestProgress?.trim() || undefined,
68
+ headline: node?.headline?.text,
69
+ quietForMs: node?.stall?.quietForMs,
70
+ };
71
+ });
72
+ }
73
+
74
+ /** Compact quiet-duration text for the stall tell, e.g. "quiet 4m". */
75
+ function fmtQuietFor(quietForMs: number): string {
76
+ const minutes = Math.floor(quietForMs / 60_000);
77
+ if (minutes < 60) return `quiet ${Math.max(1, minutes)}m`;
78
+ const hours = Math.floor(minutes / 60);
79
+ return `quiet ${hours}h${minutes % 60 > 0 ? ` ${minutes % 60}m` : ''}`;
80
+ }
81
+
37
82
  export interface ActivitySidebarView {
38
83
  readonly now: ActivitySidebarNow;
39
84
  /** Plain-language items waiting on the user (approvals, prompts). */
@@ -124,10 +169,14 @@ export function buildActivitySidebarLines(
124
169
  ]));
125
170
  }
126
171
  for (const agent of view.now.agents.slice(0, 3)) {
127
- const text = agent.progress ? `${agent.label} — ${agent.progress}` : agent.label;
172
+ const detail = agent.headline ?? agent.progress;
173
+ const text = detail ? `${agent.label} — ${detail}` : agent.label;
174
+ const quiet = agent.quietForMs !== undefined ? ` ${fmtQuietFor(agent.quietForMs)}` : '';
175
+ const room = Math.max(4, width - 4 - getDisplayWidth(quiet));
128
176
  push(buildPanelLine(width, [
129
177
  [' » ', C.info],
130
- [truncateDisplay(text, Math.max(4, width - 4)), C.dim],
178
+ [truncateDisplay(text, room), C.dim],
179
+ ...(quiet ? [[quiet, C.warn] as [string, string]] : []),
131
180
  ]));
132
181
  }
133
182
  if (view.now.processes > 0) {
@@ -117,6 +117,8 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
117
117
  fetch: 'Fetch Safety',
118
118
  security: 'Token Rotation',
119
119
  integrations: 'Delivery Reliability',
120
+ update: 'Daemon Updates',
121
+ pricing: 'Model Pricing',
120
122
  };
121
123
 
122
124
  export const SETTING_LABELS: Partial<Record<string, string>> = {
@@ -42,7 +42,7 @@ const CATEGORY_INFO: Record<SettingsCategory, string> = {
42
42
  web: 'Web companion surface settings including host, port, public URL, and static asset path.',
43
43
  watchers: 'Polling watcher and heartbeat behavior for runtime recovery and periodic checks.',
44
44
  network: 'Outbound TLS and remote fetch network policy.',
45
- relay: 'Outbound zero-knowledge relay reachability for the connected GoodVibes daemon: an end-to-end encrypted tunnel (ECDH P-256 -> HKDF -> AES-256-GCM) that terminates INSIDE the daemon, so the relay operator only ever sees ciphertext plus connection metadata (who paired with whom, byte counts, timing) — never plaintext requests, responses, or the operator token. Default off, and gated by the relay-connect feature flag in addition to relay.enabled. These are the connected daemon\'s own settings (imported here, not live-shared): changing them in Agent does not itself start or stop the daemon\'s relay registration.',
45
+ relay: 'Outbound zero-knowledge relay reachability for the connected GoodVibes daemon: an end-to-end encrypted tunnel (ECDH P-256 -> HKDF -> AES-256-GCM) that terminates INSIDE the daemon, so the relay operator only ever sees ciphertext plus connection metadata (who paired with whom, byte counts, timing) — never plaintext requests, responses, or the operator token. relay.enabled is the relay-connect feature\'s switch. These are the connected daemon\'s own settings (imported here, not live-shared): changing them in Agent does not itself start or stop the daemon\'s relay registration.',
46
46
  orchestration: 'Visible agent orchestration limits such as recursion and active-agent caps.',
47
47
  planner: 'Planning-decomposition agent limits: decomposition strategy, max turns, token ceiling, and wall-clock timeout before falling back to the deterministic heuristic path.',
48
48
  daemon: 'Whether the local session daemon runs, and whether it is embedded in this surface process instead of spawned as a detached background service.',
@@ -56,8 +56,10 @@ const CATEGORY_INFO: Record<SettingsCategory, string> = {
56
56
  mcp: 'MCP server trust and scope review. Trust changes can expose local files, tools, databases, browsers, or remote automation depending on the server.',
57
57
  surfaces: 'Messaging and notification channel accounts such as Slack, Discord, ntfy, Telegram, chat bridges, and delivery providers.',
58
58
  release: 'Update-channel preference.',
59
+ update: "Connected-host self-update posture: whether the daemon checks for, verifies, and swaps in new releases on its own, how often it checks, and where releases are resolved from. The daemon applies these itself; the Agent only edits the shared keys.",
60
+ pricing: 'Manual model prices (USD per 1M tokens, keyed provider:model). A manual price outranks registration, provider-served, and catalog prices in the one pricing resolver; unknown models stay honestly unpriced.',
59
61
  tools: 'Tool LLM and helper model routing. Empty provider/model values inherit the active chat route unless a specific helper/tool route is set.',
60
- flags: 'Feature controls for optional behavior that can be enabled or disabled separately from normal configuration.',
62
+ flags: 'Every optional capability grouped by its settings domain: each feature is switched through a first-class domain settings key (shown per row), with its full description and related settings under the cursor.',
61
63
  atRest: 'Data-at-rest protection: whether stored content is redacted, and retention limits by age and total size.',
62
64
  learning: 'Idle-time memory consolidation: dedupe merges, confidence decay of never-referenced records, and review proposals. Off by default.',
63
65
  agents: 'Agent runtime tuning: the context-window fraction that triggers sub-agent conversation compaction, and the token budget, relevance floor, and code-chunk limit for per-turn passive knowledge/code injection.',
@@ -197,22 +199,32 @@ function formatSubscriptionRoute(route: SubscriptionEntry['activeRoute'] | Subsc
197
199
  return route ? formatProviderAuthRouteId(route) : 'n/a';
198
200
  }
199
201
 
202
+ function describeFeatureEnablement(entry: FlagEntry): string {
203
+ const { key, kind, enabledValues } = entry.feature.enablement;
204
+ if (kind === 'boolean') return `Switch: ${key} (true/false).`;
205
+ if (kind === 'enum') return `Switch: ${key} — active while set to ${(enabledValues ?? []).join(' or ')}.`;
206
+ return `Always available; its settings (${entry.feature.settings.join(', ')}) govern runtime activation directly.`;
207
+ }
208
+
200
209
  function buildFlagContext(entry: FlagEntry | null): string[] {
201
210
  if (!entry) return ['Feature Controls', 'No feature control is selected.'];
202
211
  return [
203
- entry.flag.name,
204
- `ID: ${entry.flag.id}`,
212
+ entry.feature.name,
213
+ `ID: ${entry.feature.id}`,
214
+ `Domain: ${entry.feature.domain}`,
205
215
  `State: ${entry.state}`,
206
- `Default: ${entry.flag.defaultState}`,
207
- `Tier: ${entry.flag.tier}`,
208
- `Live toggleable: ${entry.flag.runtimeToggleable ? 'yes' : 'no'}`,
216
+ `Default: ${entry.feature.defaultEnabled ? 'enabled' : 'disabled'}`,
217
+ `Current value: ${entry.feature.enablement.key} = ${entry.enablementValue}`,
218
+ `Live toggleable: ${entry.feature.restartRequired ? 'no' : 'yes'}`,
219
+ '',
220
+ entry.feature.description,
209
221
  '',
210
- entry.flag.description,
211
- ...(entry.state === 'killed' && entry.flag.killReason ? ['', `Kill reason: ${entry.flag.killReason}`] : []),
222
+ describeFeatureEnablement(entry),
223
+ `Settings: ${entry.feature.settings.join(', ')}`,
212
224
  '',
213
- entry.flag.runtimeToggleable
214
- ? 'Impact: changes apply immediately and are also persisted as an override when they differ from the default.'
215
- : 'Impact: this override is saved for the next Agent launch or owning-host reload, depending on who owns the flag.',
225
+ entry.feature.restartRequired
226
+ ? 'Impact: the domain settings key is saved now and takes effect on the next Agent launch or owning-host reload.'
227
+ : 'Impact: changes to the domain settings key apply immediately through the live settings bridge.',
216
228
  ];
217
229
  }
218
230
 
@@ -388,25 +400,25 @@ function renderSettingRows(modal: SettingsModal, width: number, height: number):
388
400
  function renderFlagRows(modal: SettingsModal, width: number, height: number): string[] {
389
401
  const rows: string[] = [];
390
402
  const items = modal.flagEntries;
391
- if (items.length === 0) return ['No feature flags registered.'];
403
+ if (items.length === 0) return ['No features registered.'];
392
404
  const selectedIndex = clamp(modal.selectedIndex, 0, items.length - 1);
393
- const nameWidth = clamp(Math.floor(width * 0.40), 24, 58);
405
+ const nameWidth = clamp(Math.floor(width * 0.34), 22, 52);
394
406
  const stateWidth = 10;
395
- const tierWidth = 6;
407
+ const domainWidth = 13;
396
408
  const runtimeWidth = 10;
397
409
  const defaultWidth = 9;
398
- const idWidth = Math.max(12, width - nameWidth - stateWidth - tierWidth - runtimeWidth - defaultWidth - 14);
399
- rows.push(` ${padDisplay('Feature Flag', nameWidth)} ${padDisplay('State', stateWidth)} ${padDisplay('Tier', tierWidth)} ${padDisplay('Applies', runtimeWidth)} ${padDisplay('Default', defaultWidth)} ${padDisplay('ID', idWidth)}`);
410
+ const settingWidth = Math.max(16, width - nameWidth - stateWidth - domainWidth - runtimeWidth - defaultWidth - 14);
411
+ rows.push(` ${padDisplay('Feature', nameWidth)} ${padDisplay('State', stateWidth)} ${padDisplay('Domain', domainWidth)} ${padDisplay('Applies', runtimeWidth)} ${padDisplay('Default', defaultWidth)} ${padDisplay('Setting', settingWidth)}`);
400
412
  const visibleCount = Math.max(1, height - 2);
401
413
  const window = stableWindow(items.length, selectedIndex, visibleCount);
402
- if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more flag(s) above`);
414
+ if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more feature(s) above`);
403
415
  for (let index = window.start; index < window.end; index += 1) {
404
416
  const entry = items[index]!;
405
417
  const selected = index === selectedIndex;
406
418
  const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '•') : ' ';
407
- rows.push(`${marker} ${padDisplay(entry.flag.name, nameWidth)} ${padDisplay(entry.state, stateWidth)} ${padDisplay(String(entry.flag.tier), tierWidth)} ${padDisplay(entry.flag.runtimeToggleable ? 'now' : 'next run', runtimeWidth)} ${padDisplay(entry.flag.defaultState, defaultWidth)} ${padDisplay(entry.flag.id, idWidth)}`);
419
+ rows.push(`${marker} ${padDisplay(entry.feature.name, nameWidth)} ${padDisplay(entry.state, stateWidth)} ${padDisplay(entry.feature.domain, domainWidth)} ${padDisplay(entry.feature.restartRequired ? 'next run' : 'now', runtimeWidth)} ${padDisplay(entry.feature.defaultEnabled ? 'enabled' : 'disabled', defaultWidth)} ${padDisplay(`${entry.feature.enablement.key}=${entry.enablementValue}`, settingWidth)}`);
408
420
  }
409
- if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more flag(s) below`);
421
+ if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more feature(s) below`);
410
422
  return rows.slice(0, height);
411
423
  }
412
424
 
@@ -478,7 +490,7 @@ function footerText(modal: SettingsModal): string {
478
490
  if (modal.focusPane === 'categories') return 'Focus categories · Up/Down choose · Right/Enter settings · Tab pane · Esc close';
479
491
  if (modal.currentCategory === 'subscriptions') return 'Focus settings · Up/Down provider · Left categories · Tab pane · Enter review/sign out · Esc close';
480
492
  if (modal.currentCategory === 'mcp') return 'Focus settings · Up/Down server · Left categories · Tab pane · Enter edit trust · Esc close';
481
- if (modal.currentCategory === 'flags') return 'Focus feature flags · Up/Down flag · Left categories · Tab pane · Enter/Space toggle · Esc close';
493
+ if (modal.currentCategory === 'flags') return 'Focus features · Up/Down feature · Left categories · Tab pane · Enter/Space toggle · Esc close';
482
494
  const selected = modal.getSelected();
483
495
  if (selected && isExternalHostOwnedSettingKey(selected.setting.key)) {
484
496
  return 'Read-only connected-host setting · Change from GoodVibes TUI or the owning host · Esc close';
@@ -495,9 +507,9 @@ export function renderSettingsModalPackageText(): string {
495
507
  'Type',
496
508
  'Source',
497
509
  'Default',
498
- 'Feature Flag',
510
+ 'Feature',
499
511
  'State',
500
- 'Tier',
512
+ 'Domain',
501
513
  'Applies',
502
514
  'Server',
503
515
  'Trust',
@@ -509,7 +521,7 @@ export function renderSettingsModalPackageText(): string {
509
521
  'OAuth',
510
522
  'Note',
511
523
  'No settings in this category.',
512
- 'No feature flags registered.',
524
+ 'No features registered.',
513
525
  'No MCP servers registered.',
514
526
  'No provider subscriptions available or configured.',
515
527
  'No setting is selected in this category.',
@@ -531,7 +543,7 @@ export function renderSettingsModalPackageText(): string {
531
543
  'Focus categories · Up/Down choose · Right/Enter settings · Tab pane · Esc close',
532
544
  'Focus settings · Up/Down provider · Left categories · Tab pane · Enter review/sign out · Esc close',
533
545
  'Focus settings · Up/Down server · Left categories · Tab pane · Enter edit trust · Esc close',
534
- 'Focus feature flags · Up/Down flag · Left categories · Tab pane · Enter/Space toggle · Esc close',
546
+ 'Focus features · Up/Down feature · Left categories · Tab pane · Enter/Space toggle · Esc close',
535
547
  'Read-only connected-host setting · Change from GoodVibes TUI or the owning host · Esc close',
536
548
  'Focus settings · Up/Down setting · Left categories · Tab pane · Enter/Space edit/toggle · R reset · Esc close',
537
549
  ];
@@ -17,7 +17,7 @@ import type { OrchestratorUserInputOptions } from '../core/orchestrator.ts';
17
17
  import type { ControlPlaneRecentEvent } from '@pellux/goodvibes-sdk/platform/control-plane';
18
18
  import type { MutableRuntimeState } from '@/runtime/index.ts';
19
19
  import type { BootstrapOptions } from './context.ts';
20
- import { createFeatureFlagManager } from '@/runtime/index.ts';
20
+ import { createFeatureFlagManager, deriveFeatureStates, bindFeatureSettingsBridge } from '@/runtime/index.ts';
21
21
  import { RuntimeEventBus } from '@/runtime/index.ts';
22
22
  import type { SessionEvent } from '@/runtime/index.ts';
23
23
  import { emitPermissionModeChanged } from '@pellux/goodvibes-sdk/platform/runtime/emitters';
@@ -178,10 +178,12 @@ export async function initializeBootstrapCore(
178
178
  const homeDirectory = options.homeDirectory;
179
179
  const configManager = options.configManager;
180
180
 
181
+ // Gate states derive from domain settings keys (behavior.*, sandbox.*, ...);
182
+ // the featureFlags config category no longer exists. The live bridge keeps
183
+ // config.set changes on those keys flowing into the manager afterwards.
181
184
  const featureFlags = createFeatureFlagManager();
182
- featureFlags.loadFromConfig({
183
- flags: (configManager.getCategory('featureFlags') as Record<string, import('@/runtime/index.ts').FlagState>) ?? {},
184
- });
185
+ featureFlags.loadFromConfig({ flags: deriveFeatureStates(configManager) });
186
+ bindFeatureSettingsBridge(configManager, featureFlags);
185
187
 
186
188
  const userSessionId = `user-${generateUserSessionId()}`;
187
189
  // Declared here, before createRuntimeServices, so the checkpoint manager can be
@@ -367,6 +369,11 @@ export async function initializeBootstrapCore(
367
369
  // everything set here.
368
370
  const agentOrchestratorToolDeps = {
369
371
  surfaceRoot: GOODVIBES_AGENT_SURFACE_ROOT,
372
+ // Same instances services.ts wired at construction — setDependencies()
373
+ // fully replaces, so the localhost fetch ask and the announce-once
374
+ // containment receipt must be replayed here or they silently vanish.
375
+ localhostFetchApproval: services.localhostFetchApproval,
376
+ onSandboxedRun: services.onSandboxedRun,
370
377
  fileCache,
371
378
  projectIndex,
372
379
  workingDirectory: services.workingDirectory,
@@ -585,6 +592,10 @@ export async function initializeBootstrapCore(
585
592
  policyRuntimeState,
586
593
  services.hookDispatcher,
587
594
  featureFlags,
595
+ // Durable user-origin permission rules (remembered approvals), mirroring
596
+ // the SDK composition root: "always allow" style decisions persist across
597
+ // restarts and are listable/deletable via permissions.rules.*.
598
+ services.userPermissionRuleStore,
588
599
  );
589
600
  installPermissionManagerSafetyGuard(permissionManager);
590
601
  // Wire permissionManager into the SAME AgentOrchestrator instance that runs
@@ -630,7 +641,7 @@ export async function initializeBootstrapCore(
630
641
  },
631
642
  }).catch((err) => { logger.debug('session broker create session failed at bootstrap', { err }); });
632
643
 
633
- // W2A: mirror the create into the daemon spine (fire-and-forget; the local
644
+ // Mirror the create into the daemon spine (fire-and-forget; the local
634
645
  // broker above stays the source of truth). kind:'agent' is the canonical record
635
646
  // identity; participant.surfaceKind stays 'service' (the TRANSPORT axis).
636
647
  services.sessionSpineClient.register({