@pellux/goodvibes-tui 1.24.2 → 1.26.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 (77) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/bin/goodvibes-daemon +24 -0
  4. package/docs/foundation-artifacts/operator-contract.json +18234 -12253
  5. package/package.json +8 -7
  6. package/src/cli/help.ts +10 -0
  7. package/src/cli/redaction.ts +68 -3
  8. package/src/config/goodvibes-home.ts +149 -0
  9. package/src/config/secret-config.ts +64 -8
  10. package/src/core/turn-event-wiring.ts +7 -16
  11. package/src/core/workstream-notification.ts +69 -0
  12. package/src/daemon/cli.ts +62 -7
  13. package/src/daemon/handlers/credentials.ts +15 -5
  14. package/src/daemon/handlers/index.ts +7 -6
  15. package/src/daemon/send/channels.ts +200 -0
  16. package/src/daemon/send/command.ts +332 -0
  17. package/src/daemon/send/composition.ts +99 -0
  18. package/src/daemon/send/failure-text.ts +93 -0
  19. package/src/daemon/send/inert-text.ts +225 -0
  20. package/src/daemon/send/stdin.ts +24 -0
  21. package/src/input/commands/calendar-runtime.ts +245 -0
  22. package/src/input/commands/config.ts +68 -2
  23. package/src/input/commands/connection-status.ts +258 -0
  24. package/src/input/commands/local-provider-runtime.ts +9 -1
  25. package/src/input/commands/mail-runtime.ts +267 -0
  26. package/src/input/commands/operator-runtime.ts +6 -1
  27. package/src/input/commands/payment-card-intake.ts +200 -0
  28. package/src/input/commands/profile-render.ts +250 -0
  29. package/src/input/commands/profile-runtime.ts +362 -0
  30. package/src/input/commands/profile-types.ts +381 -0
  31. package/src/input/commands/review-runtime.ts +13 -1
  32. package/src/input/commands.ts +11 -0
  33. package/src/input/composer-editor.ts +4 -0
  34. package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
  35. package/src/input/payments-config.ts +167 -0
  36. package/src/input/provider-key-intake.ts +10 -2
  37. package/src/input/settings-modal-commit.ts +146 -0
  38. package/src/input/settings-modal-connections.ts +102 -0
  39. package/src/input/settings-modal-data.ts +30 -0
  40. package/src/input/settings-modal-reset.ts +6 -2
  41. package/src/input/settings-modal-secrets.ts +9 -2
  42. package/src/input/settings-modal-types.ts +19 -3
  43. package/src/input/settings-modal.ts +54 -107
  44. package/src/main.ts +7 -2
  45. package/src/panels/modals/marketplace-modal.ts +5 -0
  46. package/src/panels/modals/skills-modal.ts +5 -0
  47. package/src/renderer/settings-modal-connections.ts +63 -0
  48. package/src/renderer/settings-modal-helpers.ts +6 -0
  49. package/src/renderer/settings-modal.ts +21 -1
  50. package/src/runtime/bootstrap-core.ts +1 -1
  51. package/src/runtime/bootstrap.ts +2 -2
  52. package/src/runtime/context.ts +9 -0
  53. package/src/runtime/credential-composition.ts +54 -0
  54. package/src/runtime/daemon-handler-composition.ts +0 -4
  55. package/src/runtime/install-self-check-startup.ts +23 -0
  56. package/src/runtime/mail-composition.ts +65 -0
  57. package/src/runtime/onboarding/types.ts +6 -1
  58. package/src/runtime/path-shadow-startup.ts +243 -0
  59. package/src/runtime/path-shadow.ts +418 -0
  60. package/src/runtime/reachability-notice.ts +100 -0
  61. package/src/runtime/runtime-services-types.ts +243 -0
  62. package/src/runtime/services.ts +30 -224
  63. package/src/shell/ui-openers.ts +7 -0
  64. package/src/verification/verification-ledger.ts +158 -0
  65. package/src/version.ts +1 -1
  66. package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
  67. package/src/daemon/handlers/calendar/ics.ts +0 -556
  68. package/src/daemon/handlers/calendar/index.ts +0 -316
  69. package/src/daemon/handlers/email/config.ts +0 -164
  70. package/src/daemon/handlers/email/imap-connector.ts +0 -441
  71. package/src/daemon/handlers/email/imap-parsing.ts +0 -499
  72. package/src/daemon/handlers/email/index.ts +0 -43
  73. package/src/daemon/handlers/email/read-handlers.ts +0 -80
  74. package/src/daemon/handlers/email/runtime.ts +0 -140
  75. package/src/daemon/handlers/email/smtp-connector.ts +0 -557
  76. package/src/daemon/handlers/email/validation.ts +0 -147
  77. package/src/daemon/handlers/email/write-handlers.ts +0 -133
@@ -21,16 +21,14 @@ import type { ModelPickerTarget } from './model-picker.ts';
21
21
  import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
22
22
  import type { SubscriptionManager } from '@pellux/goodvibes-sdk/platform/config';
23
23
  import type { ServiceInspectionQuery } from '../runtime/ui-service-queries.ts';
24
- import { isSecretConfigKey } from '../config/secret-config.ts';
25
- import {
26
- setSecretBackedSettingValue,
27
- type SettingsSecretsManager,
28
- } from './settings-modal-secrets.ts';
24
+ import type { SettingsSecretsManager } from './settings-modal-secrets.ts';
25
+ import { CVV_PROMPT_TRADEOFF_WARNING } from '@pellux/goodvibes-sdk/platform/payments';
26
+ import { PAYMENTS_CVV_HANDLING_CONFIG_KEY } from './payments-config.ts';
29
27
  import type { FeatureFlagManager } from '@/runtime/index.ts';
30
28
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
31
- import { isWorktreeSetupListConfigKey, parseWorktreeSetupListInput } from './worktree-setup-config.ts';
32
- import { isSandboxExecListConfigKey, parseSandboxExecListInput } from './sandbox-exec-config.ts';
33
- import { isExecEnvScrubAllowlistConfigKey, parseExecEnvScrubAllowlistInput } from './exec-env-scrub-config.ts';
29
+ import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
30
+ import type { ConnectionStatus } from './commands/connection-status.ts';
31
+ import { initialConnectionEntries, refreshConnectionEntries } from './settings-modal-connections.ts';
34
32
 
35
33
  import {
36
34
  SETTINGS_CATEGORIES,
@@ -71,6 +69,7 @@ import {
71
69
  handleResetConfirmKey as _handleResetConfirmKey,
72
70
  type ResetConfirmKeyResult,
73
71
  } from './settings-modal-reset.ts';
72
+ import { commitEditValue as _commitEditValue } from './settings-modal-commit.ts';
74
73
 
75
74
  export interface SettingsModalChange {
76
75
  readonly key: ConfigKey;
@@ -86,6 +85,10 @@ export type SettingsModalChangeHandler = (change: SettingsModalChange) => Settin
86
85
 
87
86
  export interface SettingsModalOpenOptions {
88
87
  readonly onSettingApplied?: SettingsModalChangeHandler;
88
+ /** In-process catalog the Connections category probes; see that module. */
89
+ readonly gatewayMethods?: GatewayMethodCatalog;
90
+ /** Called when an async connection refresh has new rows to paint. */
91
+ readonly requestRender?: () => void;
89
92
  }
90
93
 
91
94
  export {
@@ -154,6 +157,11 @@ export class SettingsModal {
154
157
  public mcpEntries: McpEntry[] = [];
155
158
  /** Provider subscription entries (populated when subscriptions tab is active). */
156
159
  public subscriptionEntries: SubscriptionEntry[] = [];
160
+ /** Connections-tab state; all of it driven by settings-modal-connections.ts. */
161
+ public connectionEntries: ConnectionStatus[] = initialConnectionEntries();
162
+ public gatewayMethods: GatewayMethodCatalog | null = null;
163
+ public requestRender: (() => void) | null = null;
164
+ public connectionsRefreshing = false;
157
165
 
158
166
  /**
159
167
  * Whether the user has entered search mode (pressed / or a printable key
@@ -217,6 +225,9 @@ export class SettingsModal {
217
225
  this.groups = buildSettingGroups(configManager, featureFlagManager);
218
226
  this.mcpEntries = buildMcpEntries(this.mcpRegistry);
219
227
  this.subscriptionEntries = buildSubscriptionEntries(subscriptionManager, serviceRegistry);
228
+ this.gatewayMethods = options?.gatewayMethods ?? null;
229
+ this.requestRender = options?.requestRender ?? null;
230
+ this.connectionEntries = initialConnectionEntries();
220
231
  this.categoryIndex = 0;
221
232
  this.selectedIndex = 0;
222
233
  this.focusPane = 'categories';
@@ -256,6 +267,11 @@ export class SettingsModal {
256
267
  this.searchResults = [];
257
268
  this.searchFocused = false;
258
269
  this.contextScroll = 0;
270
+ // A probe still in flight checks `active` before writing, so a reopen
271
+ // starts from `checking` rather than from a finished session's answer.
272
+ this.gatewayMethods = null;
273
+ this.requestRender = null;
274
+ this.connectionEntries = initialConnectionEntries();
259
275
  this.serviceRegistry = null;
260
276
  this.secretsManager = null;
261
277
  this.onSettingApplied = null;
@@ -357,6 +373,8 @@ export class SettingsModal {
357
373
  } else if (this.currentCategory === 'subscriptions' && this.subscriptionEntries.length > 0) {
358
374
  this.selectedIndex = (this.selectedIndex - 1 + this.subscriptionEntries.length) % this.subscriptionEntries.length;
359
375
  this.subscriptionLogoutConfirmationTarget = null;
376
+ } else if (this.currentCategory === 'connections' && this.connectionEntries.length > 0) {
377
+ this.selectedIndex = (this.selectedIndex - 1 + this.connectionEntries.length) % this.connectionEntries.length;
360
378
  }
361
379
  return;
362
380
  }
@@ -380,6 +398,8 @@ export class SettingsModal {
380
398
  } else if (this.currentCategory === 'subscriptions' && this.subscriptionEntries.length > 0) {
381
399
  this.selectedIndex = (this.selectedIndex + 1) % this.subscriptionEntries.length;
382
400
  this.subscriptionLogoutConfirmationTarget = null;
401
+ } else if (this.currentCategory === 'connections' && this.connectionEntries.length > 0) {
402
+ this.selectedIndex = (this.selectedIndex + 1) % this.connectionEntries.length;
383
403
  }
384
404
  return;
385
405
  }
@@ -531,105 +551,22 @@ export class SettingsModal {
531
551
  * Returns true on success, false if validation failed.
532
552
  */
533
553
  commitEdit(): boolean {
534
- if (!this.editingMode) return false;
535
-
536
- if (this.currentCategory === 'mcp') {
537
- const entry = this.getSelectedMcp();
538
- if (!entry || !this.mcpRegistry) return false;
539
- if (this.mcpAllowAllConfirmationTarget) {
540
- const expected = `ALLOW ALL ${this.mcpAllowAllConfirmationTarget}`;
541
- if (this.editBuffer.trim() !== expected) {
542
- return false;
543
- }
544
- this.mcpRegistry.setServerTrustMode(entry.name, 'allow-all');
545
- this.mcpEntries = buildMcpEntries(this.mcpRegistry);
546
- this.editingMode = false;
547
- this.editBuffer = '';
548
- this.mcpAllowAllConfirmationTarget = null;
549
- return true;
550
- }
551
-
552
- const nextMode = this.editBuffer.trim() as McpEntry['trustMode'];
553
- const validModes: McpEntry['trustMode'][] = ['constrained', 'ask-on-risk', 'allow-all', 'blocked'];
554
- if (!validModes.includes(nextMode)) {
555
- this.editingMode = false;
556
- this.editBuffer = '';
557
- this.mcpAllowAllConfirmationTarget = null;
558
- return false;
559
- }
560
- if (nextMode === 'allow-all' && entry.trustMode !== 'allow-all') {
561
- this.mcpAllowAllConfirmationTarget = entry.name;
562
- this.editBuffer = '';
563
- return false;
564
- }
565
- this.mcpRegistry.setServerTrustMode(entry.name, nextMode);
566
- this.mcpEntries = buildMcpEntries(this.mcpRegistry);
567
- this.editingMode = false;
568
- this.editBuffer = '';
569
- this.mcpAllowAllConfirmationTarget = null;
570
- return true;
571
- }
572
-
573
- const entry = this.getSelected();
574
- if (!entry || !this.configManager) return false;
575
-
576
- const { setting } = entry;
577
- let parsed: unknown = this.editBuffer;
578
-
579
- if (setting.type === 'number') {
580
- parsed = Number(this.editBuffer);
581
- if (isNaN(parsed as number)) {
582
- this.editingMode = false;
583
- this.editBuffer = '';
584
- return false;
585
- }
586
- }
587
-
588
- if (setting.type === 'object') {
589
- // Object-typed keys (e.g. pricing.modelPrices) edit as JSON; the
590
- // schema's validate() below still rules on the parsed shape.
591
- try {
592
- parsed = JSON.parse(this.editBuffer);
593
- } catch {
594
- this.editingMode = false;
595
- this.editBuffer = '';
596
- return false;
597
- }
598
- }
599
-
600
- if (setting.validate && !setting.validate(parsed)) {
601
- this.editingMode = false;
602
- this.editBuffer = '';
603
- return false;
604
- }
605
-
606
- if (isWorktreeSetupListConfigKey(setting.key)) {
607
- // Comma-separated display/edit convention for the array-backed
608
- // worktree.setup.* keys — see worktree-setup-config.ts.
609
- this._setValue(setting.key, parseWorktreeSetupListInput(this.editBuffer));
610
- } else if (isSandboxExecListConfigKey(setting.key)) {
611
- // Same comma-separated convention for the array-backed
612
- // sandbox.egressAllowlist / sandbox.workspaceWritable keys — see
613
- // sandbox-exec-config.ts.
614
- this._setValue(setting.key, parseSandboxExecListInput(this.editBuffer));
615
- } else if (isExecEnvScrubAllowlistConfigKey(setting.key)) {
616
- // Same comma-separated convention for permissions.execEnvScrubAllowlist
617
- // — see exec-env-scrub-config.ts.
618
- this._setValue(setting.key, parseExecEnvScrubAllowlistInput(this.editBuffer));
619
- } else if (setting.type === 'string' && isSecretConfigKey(setting.key)) {
620
- setSecretBackedSettingValue({
621
- key: setting.key,
622
- value: String(parsed ?? ''),
623
- configManager: this.configManager,
624
- secretsManager: this.secretsManager,
625
- setConfigValue: (key, value) => this._setValue(key, value),
626
- });
627
- } else {
628
- this._setValue(setting.key as ConfigKey, parsed);
629
- }
630
- this.editingMode = false;
631
- this.editBuffer = '';
632
- return true;
554
+ return _commitEditValue({
555
+ editingMode: this.editingMode,
556
+ currentCategory: this.currentCategory,
557
+ editBuffer: this.editBuffer,
558
+ configManager: this.configManager,
559
+ secretsManager: this.secretsManager,
560
+ mcpRegistry: this.mcpRegistry,
561
+ mcpAllowAllConfirmationTarget: this.mcpAllowAllConfirmationTarget,
562
+ getSelectedMcp: () => this.getSelectedMcp(),
563
+ getSelected: () => this.getSelected(),
564
+ setValue: (key, value) => this._setValue(key, value),
565
+ setEditingMode: (v) => { this.editingMode = v; },
566
+ setEditBuffer: (v) => { this.editBuffer = v; },
567
+ setMcpEntries: (entries) => { this.mcpEntries = entries; },
568
+ setMcpAllowAllConfirmationTarget: (v) => { this.mcpAllowAllConfirmationTarget = v; },
569
+ });
633
570
  }
634
571
 
635
572
  /** Cancel inline edit without saving. */
@@ -702,6 +639,8 @@ export class SettingsModal {
702
639
  this.mcpEntries = buildMcpEntries(this.mcpRegistry);
703
640
  } else if (this.currentCategory === 'subscriptions') {
704
641
  this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
642
+ } else if (this.currentCategory === 'connections') {
643
+ void refreshConnectionEntries(this);
705
644
  }
706
645
  }
707
646
 
@@ -714,6 +653,7 @@ export class SettingsModal {
714
653
  if (
715
654
  this.currentCategory === 'mcp'
716
655
  || this.currentCategory === 'subscriptions'
656
+ || this.currentCategory === 'connections'
717
657
  ) return [];
718
658
  const items = this.groups.get(this.currentCategory) ?? [];
719
659
  if (this.currentCategory === 'network') {
@@ -754,6 +694,13 @@ export class SettingsModal {
754
694
  this.lastSettingEffectMessage = result.effectMessage;
755
695
  }
756
696
  // No-op (result.changed === false, effectMessage === null): leave lastSettingEffectMessage untouched.
697
+
698
+ // Selecting 'prompt' for payments.cvvHandling states the tradeoff at the
699
+ // moment of selection, not just in the documentation pane — see the
700
+ // SDK's own CVV_PROMPT_TRADEOFF_WARNING (platform/payments).
701
+ if (key === PAYMENTS_CVV_HANDLING_CONFIG_KEY && value === 'prompt') {
702
+ this.lastSettingEffectMessage = CVV_PROMPT_TRADEOFF_WARNING;
703
+ }
757
704
  }
758
705
 
759
706
  /** Refresh live/persisted/pending state for every feature header bound to `key`. */
package/src/main.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- import { homedir } from 'node:os';
2
+ import { resolveGoodVibesDaemonHome, resolveGoodVibesHome } from './config/goodvibes-home.ts';
3
3
  import { Compositor } from './renderer/compositor.ts';
4
4
  import { type Line } from './types/grid.ts';
5
5
  import { UIFactory } from './renderer/ui-factory.ts';
@@ -87,9 +87,12 @@ import { VERSION } from './version.ts';
87
87
  async function main() {
88
88
  const stdout = process.stdout;
89
89
  const stdin = process.stdin;
90
+ // Both roots come from the one resolver the daemon CLI also uses. This line
91
+ // called homedir() unconditionally, so a harness that redirected the tree got
92
+ // a client that wrote the real one — secret store included.
90
93
  const { cli, configManager, bootstrapWorkingDir, bootstrapHomeDirectory } = await prepareShellCliRuntime(process.argv.slice(2), {
91
94
  defaultWorkingDirectory: process.env['GOODVIBES_WORKING_DIR'] ?? process.cwd(),
92
- homeDirectory: homedir(),
95
+ homeDirectory: resolveGoodVibesHome(),
93
96
  }, 'goodvibes');
94
97
 
95
98
  // Between binary start and the first frame, boot used to be silent (config
@@ -106,6 +109,8 @@ async function main() {
106
109
  configManager,
107
110
  workingDir: bootstrapWorkingDir,
108
111
  homeDirectory: bootstrapHomeDirectory,
112
+ // Named, so GOODVIBES_DAEMON_HOME on its own moves the client's daemon tier.
113
+ daemonHomeDirectory: resolveGoodVibesDaemonHome(bootstrapHomeDirectory),
109
114
  });
110
115
  const {
111
116
  conversation,
@@ -207,6 +207,11 @@ export function createMarketplaceModalSurface(deps: MarketplaceModalDeps): Confi
207
207
  * missing paths with existsSync → [], so refresh() finds nothing and renders the
208
208
  * honest empty-state copy). The random tmp path never appears in the
209
209
  * rendered lines (the empty-state copy is static), so the golden is byte-stable.
210
+ *
211
+ * This is production code (ships in the real binary), not test scratch, so
212
+ * it stays rooted at the real OS temp dir rather than the test-only
213
+ * makeProjectTempDir helper. Created and removed synchronously in the same
214
+ * call, so the on-disk window is negligible.
210
215
  */
211
216
  export function marketplaceModalGoldenSurface(): ConfigModalSurface {
212
217
  const root = mkdtempSync(join(tmpdir(), 'gv-marketplace-golden-'));
@@ -180,6 +180,11 @@ export function createSkillsModalSurface(deps: SkillsModalDeps): ConfigModalSurf
180
180
  * homeDir and removed immediately (scanSkillDirectorySync catches missing dirs →
181
181
  * []), so refresh() finds no `.goodvibes/skills` directories and renders the
182
182
  * static empty-state copy. The random tmp path never appears in the output.
183
+ *
184
+ * This is production code (ships in the real binary), not test scratch, so
185
+ * it stays rooted at the real OS temp dir rather than the test-only
186
+ * makeProjectTempDir helper. Created and removed synchronously in the same
187
+ * call, so the on-disk window is negligible.
183
188
  */
184
189
  export function skillsModalGoldenSurface(): ConfigModalSurface {
185
190
  const root = mkdtempSync(join(tmpdir(), 'gv-skills-golden-'));
@@ -0,0 +1,63 @@
1
+ /**
2
+ * settings-modal-connections.ts — rendering for the Connections category of the
3
+ * settings workspace.
4
+ *
5
+ * Split out of settings-modal.ts for the 800-line file cap, the same reason
6
+ * settings-modal-helpers.ts exists. Pure rendering: every value shown here was
7
+ * decided by the daemon probe in input/commands/connection-status.ts.
8
+ */
9
+
10
+ import type { SettingsModal } from '../input/settings-modal.ts';
11
+ import { selectedConnectionEntry } from '../input/settings-modal-connections.ts';
12
+ import { connectionSurfaceLabel, type ConnectionStatus } from '../input/commands/connection-status.ts';
13
+ import { GLYPHS } from './ui-primitives.ts';
14
+ import { clamp, padDisplay, stableWindow } from './fullscreen-workspace.ts';
15
+
16
+ /**
17
+ * The Connections rows: one line per surface, saying what it actually is.
18
+ *
19
+ * `checking` is rendered as its own state rather than blanked, because a row
20
+ * that showed nothing while the probe was in flight would read as "nothing
21
+ * configured" — the one thing this category exists to stop claiming falsely.
22
+ */
23
+ export function renderConnectionRows(modal: SettingsModal, width: number, height: number): string[] {
24
+ const rows: string[] = [];
25
+ const items = modal.connectionEntries;
26
+ if (items.length === 0) return ['No connection surfaces are known to this build.'];
27
+ const selectedIndex = clamp(modal.selectedIndex, 0, items.length - 1);
28
+ const surfaceWidth = clamp(Math.floor(width * 0.16), 10, 20);
29
+ const stateWidth = 12;
30
+ const detailWidth = Math.max(16, width - surfaceWidth - stateWidth - 8);
31
+ rows.push(` ${padDisplay('Surface', surfaceWidth)} ${padDisplay('State', stateWidth)} ${padDisplay('Detail', detailWidth)}`);
32
+ const window = stableWindow(items.length, selectedIndex, Math.max(1, height - 2));
33
+ if (window.start > 0) rows.push(`${GLYPHS.navigation.moreAbove} ${window.start} more connection(s) above`);
34
+ for (let index = window.start; index < window.end; index += 1) {
35
+ const entry = items[index]!;
36
+ const selected = index === selectedIndex;
37
+ const marker = selected ? (modal.focusPane === 'settings' ? GLYPHS.navigation.selected : '\u2022') : ' ';
38
+ rows.push(`${marker} ${padDisplay(connectionSurfaceLabel(entry.surface), surfaceWidth)} ${padDisplay(entry.state, stateWidth)} ${padDisplay(entry.detail, detailWidth)}`);
39
+ }
40
+ if (window.end < items.length) rows.push(`${GLYPHS.navigation.moreBelow} ${items.length - window.end} more connection(s) below`);
41
+ return rows.slice(0, height);
42
+ }
43
+
44
+ /**
45
+ * The detail pane for one connection: the state, what it means, and every next
46
+ * step verbatim. The next steps are the daemon's requirements, so they are
47
+ * listed in full rather than summarized — a truncated instruction is a wrong
48
+ * instruction.
49
+ */
50
+ export function buildConnectionContext(modal: SettingsModal): string[] {
51
+ const entry = selectedConnectionEntry(modal);
52
+ if (!entry) return ['Connections', 'No connection surface is selected.'];
53
+ return [
54
+ connectionSurfaceLabel(entry.surface),
55
+ `State: ${entry.state}`,
56
+ entry.detail,
57
+ ...(entry.nextActions.length > 0 ? ['', 'Next steps:', ...entry.nextActions] : []),
58
+ '',
59
+ entry.surface === 'mail'
60
+ ? 'Use it from the transcript with /mail (status, list, read, draft, send).'
61
+ : 'Use it from the transcript with /calendar (status, list, get, create).',
62
+ ];
63
+ }
@@ -107,10 +107,13 @@ export const CATEGORY_INFO: Record<SettingsCategory, string> = {
107
107
  agents: 'Sub-agent context-window awareness and per-turn passive knowledge/code injection: token budget, relevance floor, code-chunk limit, and the compaction threshold. Gated by the agent context/injection features.',
108
108
  security: 'API token scope and rotation auditing: rotation cadence, warning lead time, and whether overdue/over-scoped tokens are blocked or only reported. Gated by the Token Scope and Rotation Audit feature.',
109
109
  integrations: 'Integration delivery reliability (Slack/Discord/webhook): retry counts, backoff bounds, dead-letter queue size, and SLO enforcement. Gated by the Integration Delivery SLO feature.',
110
+ connections: 'Whether mail and calendar are actually usable right now. Each row is answered by asking the daemon that owns the connection, not by reading config here, so a row says ready only when a real request succeeded.',
110
111
  policy: 'Policy-as-code bundle loading: where the startup policy bundle is loaded from and its file path. Gated by the Policy-as-Code feature.',
111
112
  notifications: 'Notification router burst-suppression tuning: burst window, threshold, and cooldown. Gated by the Adaptive Notification Suppression feature.',
112
113
  relay: 'Outbound zero-knowledge relay reachability, for reaching this daemon from outside the LAN without opening an inbound port. Off by default — relay.enabled is the switch. The relay operator sees only ciphertext and connection metadata — self-host your own relay for full control.',
113
114
  learning: 'Idle-time memory consolidation: merging duplicate standing memory records and decaying/archiving stale ones. Off by default — nothing runs until enabled.',
115
+ payments: 'Payment card on file for daemon-initiated purchases: card number, expiry, CVV, cardholder name, billing and shipping address, and whether the CVV is stored or requested at purchase time. Card fields are entered through /payments card (concealed input) and are secret-tier — never rendered in plaintext.',
116
+ profile: 'What the platform knows about you — your name, how to reach you, where you are, how you like answers written, the people and places you mention — kept as one Markdown file at daemon scope that you can open and edit by hand at any time; your edits win and are never rewritten. These rows decide whether it is loaded at all, whether the assistant records facts it learns from things you say directly to it, whether it tells you in one line each time it does, whether the harmless part (how you are addressed, your city, your timezone, your formatting preferences, your style) is put in the model\'s context so it stops guessing, whether using a private detail such as your address is announced in the reply, whether settings left unset read their value from the profile, and where the file lives. Everything private — your name, contact details, home address, billing and shipping details, quiet hours, and the People, Places, Work and Notes sections — is never put in context in bulk and is reachable only by a named lookup that is disclosed when it happens. Run /profile to read the whole thing, /profile where <field> to see the surface, date and your exact words behind any recorded line, and /profile forget <field> to delete one.',
114
117
  };
115
118
 
116
119
  export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], string> = {
@@ -161,8 +164,11 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
161
164
  agents: 'Agents & Context',
162
165
  security: 'Token Security',
163
166
  integrations: 'Integration Delivery',
167
+ connections: 'Connections',
164
168
  policy: 'Policy-as-Code',
165
169
  notifications: 'Notifications',
170
+ payments: 'Payments',
171
+ profile: 'Owner Profile',
166
172
  };
167
173
 
168
174
  const SETTING_LABELS: Partial<Record<string, string>> = {
@@ -11,7 +11,10 @@ import { SETTINGS_CATEGORIES, SETTINGS_CATEGORY_GROUPS } from '../input/settings
11
11
  import { FEATURE_SETTINGS_BY_ID, isFeatureValueEnabled } from '../runtime/feature-settings.ts';
12
12
  import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
13
13
  import { CATEGORY_INFO, CATEGORY_LABELS, describeUiRouting, formatValue, getSettingLabel, inferSubscriptionRouteReason, valueColor } from './settings-modal-helpers.ts';
14
+ import { buildConnectionContext, renderConnectionRows } from './settings-modal-connections.ts';
14
15
  import { isSecretConfigKey } from '../config/secret-config.ts';
16
+ import { maskConcealedText } from '../input/concealed-input.ts';
17
+ import { CVV_PROMPT_TRADEOFF_WARNING } from '@pellux/goodvibes-sdk/platform/payments';
15
18
  import { GLYPHS } from './ui-primitives.ts';
16
19
  import { formatHints, joinHints } from './hint-grammar.ts';
17
20
  import {
@@ -104,6 +107,10 @@ const ENUM_VALUE_DESCRIPTIONS: Record<string, Record<string, string>> = {
104
107
  'meta-cloud': 'Use Meta Cloud API credentials and identifiers.',
105
108
  bridge: 'Use a bridge service URL/token flow instead of direct Meta Cloud API delivery.',
106
109
  },
110
+ 'payments.cvvHandling': {
111
+ stored: 'Store the CVV through the secret manager so the daemon can complete a purchase unattended.',
112
+ prompt: CVV_PROMPT_TRADEOFF_WARNING,
113
+ },
107
114
  };
108
115
 
109
116
  function paddedWrapped(text: string, width: number, prefix = ''): string[] {
@@ -120,7 +127,16 @@ function formatDefaultValue(value: unknown): string {
120
127
  }
121
128
 
122
129
  function currentSettingValue(modal: SettingsModal, entry: SettingEntry, selected: boolean): string {
123
- if (selected && modal.editingMode) return `${modal.editBuffer}${GLYPHS.surface.cursor}`;
130
+ if (selected && modal.editingMode) {
131
+ // Secret-backed keys (surfaces.*.botToken, .signingSecret, etc. — see
132
+ // secret-config.ts) must never echo the in-progress plaintext buffer:
133
+ // not in the row, not in the "Current: ..." context line, not in search
134
+ // results. Reuse the composer's concealed-input mask (concealed-input.ts)
135
+ // rather than a second masking implementation — same bullet-per-character
136
+ // shape, so keystrokes still visibly register without revealing content.
137
+ const buffer = isSecretConfigKey(entry.setting.key) ? maskConcealedText(modal.editBuffer) : modal.editBuffer;
138
+ return `${buffer}${GLYPHS.surface.cursor}`;
139
+ }
124
140
  return formatValue(entry);
125
141
  }
126
142
 
@@ -360,6 +376,8 @@ function buildContextLines(modal: SettingsModal, width: number): string[] {
360
376
  lines.push(...buildMcpContext(modal, modal.getSelectedMcp()));
361
377
  } else if (category === 'subscriptions') {
362
378
  lines.push(...buildSubscriptionContext(modal, modal.getSelectedSubscription()));
379
+ } else if (category === 'connections') {
380
+ lines.push(...buildConnectionContext(modal));
363
381
  } else {
364
382
  const selected = modal.getSelected();
365
383
  // A feature-unit header shows the feature's documentation (full
@@ -386,6 +404,7 @@ function buildContextLines(modal: SettingsModal, width: number): string[] {
386
404
  function categoryItemCount(modal: SettingsModal, category: SettingsCategory): number {
387
405
  if (category === 'mcp') return modal.mcpEntries.length;
388
406
  if (category === 'subscriptions') return modal.subscriptionEntries.length;
407
+ if (category === 'connections') return modal.connectionEntries.length;
389
408
  return modal.groups.get(category)?.length ?? 0;
390
409
  }
391
410
 
@@ -601,6 +620,7 @@ function renderControlRows(modal: SettingsModal, width: number, height: number):
601
620
  if (modal.searchFocused) return renderSearchRows(modal, width, height);
602
621
  if (modal.currentCategory === 'mcp') return renderMcpRows(modal, width, height);
603
622
  if (modal.currentCategory === 'subscriptions') return renderSubscriptionRows(modal, width, height);
623
+ if (modal.currentCategory === 'connections') return renderConnectionRows(modal, width, height);
604
624
  return renderSettingRows(modal, width, height);
605
625
  }
606
626
 
@@ -248,7 +248,7 @@ export async function initializeBootstrapCore(
248
248
  runtimeBus,
249
249
  runtimeStore: store,
250
250
  getConversationTitle: () => getConversationTitle(),
251
- workingDir, homeDirectory,
251
+ workingDir, homeDirectory, ...(options.daemonHomeDirectory === undefined ? {} : { daemonHomeDirectory: options.daemonHomeDirectory }),
252
252
  // Embedded topology: the in-process runtime IS the daemon and holds its own OS
253
253
  // inhibitor, so opt into the real host seam (fork default is non-spawning). See services.ts.
254
254
  powerSeam: createHostPowerSeam(),
@@ -41,7 +41,7 @@ import { initializeBootstrapCore } from './bootstrap-core.ts';
41
41
  import { ensureBootModelResolvable } from './provider-fallback.ts';
42
42
  import { createBootstrapShell } from './bootstrap-shell.ts';
43
43
  import { announceResumeState } from './resume-notice.ts';
44
- import { announceInstallSelfCheck } from './install-self-check-startup.ts';
44
+ import { announceInstallHealth } from './install-self-check-startup.ts';
45
45
  import { buildSharedOrchestratorCoreServices, refreshMemoryRecallSnapshot } from './orchestrator-core-services.ts';
46
46
  import { consumeDaemonAttachNotices, consumeExternalDaemonAttachNotices } from './daemon-attach-notices.ts';
47
47
  import { wireContextAccountingSource } from './context-accounting-source.ts';
@@ -322,7 +322,7 @@ export async function bootstrapRuntime(
322
322
  }).catch(() => {
323
323
  // Best-effort — never let the resume notice block or crash boot.
324
324
  });
325
- announceInstallSelfCheck(systemMessageRouter);
325
+ announceInstallHealth(systemMessageRouter);
326
326
  const { gitStatusProvider, inputHistory, lastGitInfoRef } = shell;
327
327
  // FIX 2: dispose the header's live-repo-state poll (git-status.ts
328
328
  // startPolling) on shutdown, same pattern as acpTaskSyncInterval above.
@@ -31,6 +31,15 @@ export interface BootstrapOptions {
31
31
  workingDir: string;
32
32
  /** App-owned home directory for this runtime instance. */
33
33
  homeDirectory: string;
34
+ /**
35
+ * The daemon's identity directory for this runtime instance.
36
+ *
37
+ * Named rather than derived so the client's daemon-TIER secret reads and
38
+ * writes follow a GOODVIBES_DAEMON_HOME set on its own. Absent, the secret
39
+ * store falls back to `<homeDirectory>/.goodvibes/daemon`, which is the same
40
+ * answer whenever that variable is unset.
41
+ */
42
+ daemonHomeDirectory?: string;
34
43
  /** Explicit app-owned config manager for this runtime instance. */
35
44
  configManager: import('@pellux/goodvibes-sdk/platform/config').ConfigManager;
36
45
  /**
@@ -0,0 +1,54 @@
1
+ /**
2
+ * credential-composition.ts
3
+ *
4
+ * The credential and identity seam: the secret store, the step-up ceremony
5
+ * service that verifies against it, and the per-device pairing tokens.
6
+ *
7
+ * These are grouped because they share one fact, and it is the fact this round
8
+ * had to fix: WHERE the daemon's own credentials live. `daemonHome` is threaded
9
+ * rather than defaulted, because without it a daemon told to run out of a temp
10
+ * tree moved its identity directory and nothing else — the credential store
11
+ * stayed in the real home, so an "isolated" test daemon held live credentials
12
+ * and long-polled a real account. A default here would silently restore that.
13
+ *
14
+ * Extracted from services.ts because that file sits at the 800-line
15
+ * architecture cap. Moving a coherent seam out is what the cap is for; trimming
16
+ * unrelated comments to buy headroom is the thing it exists to prevent.
17
+ */
18
+
19
+ import { SecretsManager } from '../config/secrets.ts';
20
+ import { StepUpService } from '@pellux/goodvibes-sdk/daemon';
21
+ import { PairingTokenManager } from '@pellux/goodvibes-sdk/platform/pairing';
22
+
23
+ interface CredentialCompositionInput {
24
+ readonly workingDirectory: string;
25
+ readonly homeDirectory: string;
26
+ /**
27
+ * The daemon's state root when the host was told one. Absent means "not
28
+ * overridden" and must stay absent rather than being defaulted — see above.
29
+ */
30
+ readonly daemonHomeDirectory?: string | undefined;
31
+ readonly configManager: ConstructorParameters<typeof SecretsManager>[0]['configManager'];
32
+ readonly pairingTokenPath: string;
33
+ }
34
+
35
+ export function composeCredentialServices(input: CredentialCompositionInput): {
36
+ readonly secretsManager: SecretsManager;
37
+ readonly stepUpService: StepUpService;
38
+ readonly pairingTokens: PairingTokenManager;
39
+ } {
40
+ const secretsManager = new SecretsManager({
41
+ projectRoot: input.workingDirectory,
42
+ globalHome: input.homeDirectory,
43
+ // Threaded, not defaulted: else an isolated daemon reads the real store.
44
+ ...(input.daemonHomeDirectory === undefined ? {} : { daemonHome: input.daemonHomeDirectory }),
45
+ configManager: input.configManager,
46
+ });
47
+ return {
48
+ secretsManager,
49
+ // Shared between the ceremony gateway verbs and the relay gate's verifier,
50
+ // so both check a step-up against the same store.
51
+ stepUpService: new StepUpService({ secrets: secretsManager }),
52
+ pairingTokens: new PairingTokenManager(input.pairingTokenPath),
53
+ };
54
+ }
@@ -27,8 +27,6 @@ import { registerRouting } from '../daemon/handlers/routing/index.ts';
27
27
  import { registerInboxMethods } from '../daemon/handlers/inbox/index.ts';
28
28
  import { registerTriagedInbox } from '../daemon/handlers/triage/index.ts';
29
29
  import { registerDraftMethods } from '../daemon/handlers/drafts/index.ts';
30
- import { registerCalendar } from '../daemon/handlers/calendar/index.ts';
31
- import { registerEmailMethods } from '../daemon/handlers/email/index.ts';
32
30
  import { registerRemoteSurface } from '../daemon/handlers/remote/index.ts';
33
31
  import { inboxPollerGate } from './cluster-composition.ts';
34
32
 
@@ -73,8 +71,6 @@ export function createDaemonHandlerComposition(
73
71
  options.clusterCoordinator.register(inboxPollerGate(providerId, control)),
74
72
  })).unregister,
75
73
  registerDrafts: (ctx) => registerDraftMethods(ctx),
76
- registerCalendar,
77
- registerEmail: (ctx) => registerEmailMethods(ctx),
78
74
  registerRemote: (ctx) => registerRemoteSurface(ctx, { manager: options.distributedRuntime }),
79
75
  });
80
76
  }
@@ -17,6 +17,7 @@
17
17
  import { existsSync } from 'node:fs';
18
18
  import { getGoodVibesPackageRoot, resolveGoodVibesDaemonExecutable } from '../cli/service-posture.ts';
19
19
  import { runInstallSelfCheck } from './install-self-check.ts';
20
+ import { announceReachability } from './path-shadow-startup.ts';
20
21
  import type { SystemMessageRouter } from '../core/system-message-router.ts';
21
22
 
22
23
  export function announceInstallSelfCheck(router: SystemMessageRouter): void {
@@ -34,3 +35,25 @@ export function announceInstallSelfCheck(router: SystemMessageRouter): void {
34
35
  // Best-effort — an install self-check must never block or crash boot.
35
36
  }
36
37
  }
38
+
39
+ /**
40
+ * The one call boot makes about the state of this install: is it complete
41
+ * (the check above), is it the copy the shell actually reaches, and is it the
42
+ * current release (path-shadow-startup).
43
+ *
44
+ * They are announced together because they are the same question from the
45
+ * user's side — "am I looking at the build I think I am" — and because the
46
+ * three answers are useless apart: a complete install nobody can reach, or a
47
+ * reachable install three versions old, both end with the product appearing
48
+ * to have lost a capability it never had.
49
+ *
50
+ * The reachability half is async (it may look up the latest release) and is
51
+ * deliberately not awaited: boot continues, the notice arrives through the
52
+ * same router a moment later.
53
+ */
54
+ export function announceInstallHealth(router: SystemMessageRouter): void {
55
+ announceInstallSelfCheck(router);
56
+ void announceReachability(router).catch(() => {
57
+ // Best-effort — a reachability check must never block or crash boot.
58
+ });
59
+ }