@pellux/goodvibes-tui 1.24.1 → 1.25.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 (41) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/bin/goodvibes-daemon +24 -0
  4. package/docs/foundation-artifacts/operator-contract.json +3237 -1096
  5. package/package.json +7 -7
  6. package/src/config/goodvibes-home.ts +139 -0
  7. package/src/daemon/cli.ts +17 -6
  8. package/src/daemon/handlers/index.ts +7 -6
  9. package/src/input/commands/calendar-runtime.ts +245 -0
  10. package/src/input/commands/connection-status.ts +258 -0
  11. package/src/input/commands/mail-runtime.ts +267 -0
  12. package/src/input/commands/review-runtime.ts +13 -1
  13. package/src/input/commands.ts +4 -0
  14. package/src/input/settings-modal-connections.ts +102 -0
  15. package/src/input/settings-modal-types.ts +2 -1
  16. package/src/input/settings-modal.ts +27 -0
  17. package/src/main.ts +7 -2
  18. package/src/renderer/settings-modal-connections.ts +63 -0
  19. package/src/renderer/settings-modal-helpers.ts +2 -0
  20. package/src/renderer/settings-modal.ts +5 -0
  21. package/src/runtime/bootstrap-core.ts +1 -1
  22. package/src/runtime/context.ts +9 -0
  23. package/src/runtime/credential-composition.ts +54 -0
  24. package/src/runtime/daemon-handler-composition.ts +0 -4
  25. package/src/runtime/mail-composition.ts +65 -0
  26. package/src/runtime/services.ts +21 -24
  27. package/src/shell/ui-openers.ts +7 -0
  28. package/src/verification/verification-ledger.ts +58 -0
  29. package/src/version.ts +1 -1
  30. package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
  31. package/src/daemon/handlers/calendar/ics.ts +0 -556
  32. package/src/daemon/handlers/calendar/index.ts +0 -316
  33. package/src/daemon/handlers/email/config.ts +0 -164
  34. package/src/daemon/handlers/email/imap-connector.ts +0 -441
  35. package/src/daemon/handlers/email/imap-parsing.ts +0 -499
  36. package/src/daemon/handlers/email/index.ts +0 -43
  37. package/src/daemon/handlers/email/read-handlers.ts +0 -80
  38. package/src/daemon/handlers/email/runtime.ts +0 -140
  39. package/src/daemon/handlers/email/smtp-connector.ts +0 -557
  40. package/src/daemon/handlers/email/validation.ts +0 -147
  41. package/src/daemon/handlers/email/write-handlers.ts +0 -133
@@ -28,6 +28,9 @@ import {
28
28
  } from './settings-modal-secrets.ts';
29
29
  import type { FeatureFlagManager } from '@/runtime/index.ts';
30
30
  import type { McpRegistry } from '@pellux/goodvibes-sdk/platform/mcp';
31
+ import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
32
+ import type { ConnectionStatus } from './commands/connection-status.ts';
33
+ import { initialConnectionEntries, refreshConnectionEntries } from './settings-modal-connections.ts';
31
34
  import { isWorktreeSetupListConfigKey, parseWorktreeSetupListInput } from './worktree-setup-config.ts';
32
35
  import { isSandboxExecListConfigKey, parseSandboxExecListInput } from './sandbox-exec-config.ts';
33
36
  import { isExecEnvScrubAllowlistConfigKey, parseExecEnvScrubAllowlistInput } from './exec-env-scrub-config.ts';
@@ -86,6 +89,10 @@ export type SettingsModalChangeHandler = (change: SettingsModalChange) => Settin
86
89
 
87
90
  export interface SettingsModalOpenOptions {
88
91
  readonly onSettingApplied?: SettingsModalChangeHandler;
92
+ /** In-process catalog the Connections category probes; see that module. */
93
+ readonly gatewayMethods?: GatewayMethodCatalog;
94
+ /** Called when an async connection refresh has new rows to paint. */
95
+ readonly requestRender?: () => void;
89
96
  }
90
97
 
91
98
  export {
@@ -154,6 +161,11 @@ export class SettingsModal {
154
161
  public mcpEntries: McpEntry[] = [];
155
162
  /** Provider subscription entries (populated when subscriptions tab is active). */
156
163
  public subscriptionEntries: SubscriptionEntry[] = [];
164
+ /** Connections-tab state; all of it driven by settings-modal-connections.ts. */
165
+ public connectionEntries: ConnectionStatus[] = initialConnectionEntries();
166
+ public gatewayMethods: GatewayMethodCatalog | null = null;
167
+ public requestRender: (() => void) | null = null;
168
+ public connectionsRefreshing = false;
157
169
 
158
170
  /**
159
171
  * Whether the user has entered search mode (pressed / or a printable key
@@ -217,6 +229,9 @@ export class SettingsModal {
217
229
  this.groups = buildSettingGroups(configManager, featureFlagManager);
218
230
  this.mcpEntries = buildMcpEntries(this.mcpRegistry);
219
231
  this.subscriptionEntries = buildSubscriptionEntries(subscriptionManager, serviceRegistry);
232
+ this.gatewayMethods = options?.gatewayMethods ?? null;
233
+ this.requestRender = options?.requestRender ?? null;
234
+ this.connectionEntries = initialConnectionEntries();
220
235
  this.categoryIndex = 0;
221
236
  this.selectedIndex = 0;
222
237
  this.focusPane = 'categories';
@@ -256,6 +271,11 @@ export class SettingsModal {
256
271
  this.searchResults = [];
257
272
  this.searchFocused = false;
258
273
  this.contextScroll = 0;
274
+ // A probe still in flight checks `active` before writing, so a reopen
275
+ // starts from `checking` rather than from a finished session's answer.
276
+ this.gatewayMethods = null;
277
+ this.requestRender = null;
278
+ this.connectionEntries = initialConnectionEntries();
259
279
  this.serviceRegistry = null;
260
280
  this.secretsManager = null;
261
281
  this.onSettingApplied = null;
@@ -357,6 +377,8 @@ export class SettingsModal {
357
377
  } else if (this.currentCategory === 'subscriptions' && this.subscriptionEntries.length > 0) {
358
378
  this.selectedIndex = (this.selectedIndex - 1 + this.subscriptionEntries.length) % this.subscriptionEntries.length;
359
379
  this.subscriptionLogoutConfirmationTarget = null;
380
+ } else if (this.currentCategory === 'connections' && this.connectionEntries.length > 0) {
381
+ this.selectedIndex = (this.selectedIndex - 1 + this.connectionEntries.length) % this.connectionEntries.length;
360
382
  }
361
383
  return;
362
384
  }
@@ -380,6 +402,8 @@ export class SettingsModal {
380
402
  } else if (this.currentCategory === 'subscriptions' && this.subscriptionEntries.length > 0) {
381
403
  this.selectedIndex = (this.selectedIndex + 1) % this.subscriptionEntries.length;
382
404
  this.subscriptionLogoutConfirmationTarget = null;
405
+ } else if (this.currentCategory === 'connections' && this.connectionEntries.length > 0) {
406
+ this.selectedIndex = (this.selectedIndex + 1) % this.connectionEntries.length;
383
407
  }
384
408
  return;
385
409
  }
@@ -702,6 +726,8 @@ export class SettingsModal {
702
726
  this.mcpEntries = buildMcpEntries(this.mcpRegistry);
703
727
  } else if (this.currentCategory === 'subscriptions') {
704
728
  this.subscriptionEntries = buildSubscriptionEntries(this.subscriptionManager, this.serviceRegistry);
729
+ } else if (this.currentCategory === 'connections') {
730
+ void refreshConnectionEntries(this);
705
731
  }
706
732
  }
707
733
 
@@ -714,6 +740,7 @@ export class SettingsModal {
714
740
  if (
715
741
  this.currentCategory === 'mcp'
716
742
  || this.currentCategory === 'subscriptions'
743
+ || this.currentCategory === 'connections'
717
744
  ) return [];
718
745
  const items = this.groups.get(this.currentCategory) ?? [];
719
746
  if (this.currentCategory === 'network') {
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,
@@ -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,6 +107,7 @@ 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.',
@@ -161,6 +162,7 @@ export const CATEGORY_LABELS: Record<(typeof SETTINGS_CATEGORIES)[number], strin
161
162
  agents: 'Agents & Context',
162
163
  security: 'Token Security',
163
164
  integrations: 'Integration Delivery',
165
+ connections: 'Connections',
164
166
  policy: 'Policy-as-Code',
165
167
  notifications: 'Notifications',
166
168
  };
@@ -11,6 +11,7 @@ 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';
15
16
  import { GLYPHS } from './ui-primitives.ts';
16
17
  import { formatHints, joinHints } from './hint-grammar.ts';
@@ -360,6 +361,8 @@ function buildContextLines(modal: SettingsModal, width: number): string[] {
360
361
  lines.push(...buildMcpContext(modal, modal.getSelectedMcp()));
361
362
  } else if (category === 'subscriptions') {
362
363
  lines.push(...buildSubscriptionContext(modal, modal.getSelectedSubscription()));
364
+ } else if (category === 'connections') {
365
+ lines.push(...buildConnectionContext(modal));
363
366
  } else {
364
367
  const selected = modal.getSelected();
365
368
  // A feature-unit header shows the feature's documentation (full
@@ -386,6 +389,7 @@ function buildContextLines(modal: SettingsModal, width: number): string[] {
386
389
  function categoryItemCount(modal: SettingsModal, category: SettingsCategory): number {
387
390
  if (category === 'mcp') return modal.mcpEntries.length;
388
391
  if (category === 'subscriptions') return modal.subscriptionEntries.length;
392
+ if (category === 'connections') return modal.connectionEntries.length;
389
393
  return modal.groups.get(category)?.length ?? 0;
390
394
  }
391
395
 
@@ -601,6 +605,7 @@ function renderControlRows(modal: SettingsModal, width: number, height: number):
601
605
  if (modal.searchFocused) return renderSearchRows(modal, width, height);
602
606
  if (modal.currentCategory === 'mcp') return renderMcpRows(modal, width, height);
603
607
  if (modal.currentCategory === 'subscriptions') return renderSubscriptionRows(modal, width, height);
608
+ if (modal.currentCategory === 'connections') return renderConnectionRows(modal, width, height);
604
609
  return renderSettingRows(modal, width, height);
605
610
  }
606
611
 
@@ -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(),
@@ -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
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * mail-composition.ts
3
+ *
4
+ * The deps that let the platform register this daemon's `email.*` verbs.
5
+ *
6
+ * `calendar.*` and `email.*` are served by the platform now, not by handlers in
7
+ * this repository — those were deleted when the platform gained an
8
+ * implementation the daemon could call. What is left for a product to supply is
9
+ * the wiring, and it is not optional: without `homeDirectory` the calendar
10
+ * composition returns null, without these deps the mail one does, and either way
11
+ * the verbs stay cataloged-but-unhandled while every surface reports the daemon
12
+ * unreachable on a daemon that is working perfectly.
13
+ *
14
+ * Extracted from services.ts rather than living there, because that file sits at
15
+ * the 800-line architecture cap and a composition step is exactly the kind of
16
+ * thing the cap exists to push into a module of its own.
17
+ */
18
+
19
+ import {
20
+ withSurfaceEmailConfig,
21
+ describeSurfaceEmailConfigProblem,
22
+ describeSenderClaimNeutrally,
23
+ type EmailServiceDeps,
24
+ type SurfaceEmailConfigProblem,
25
+ } from '@pellux/goodvibes-sdk/platform/email';
26
+ import { nodeEmailTransport } from '@pellux/goodvibes-sdk/platform/email/node';
27
+
28
+ /** The narrow slices this composition needs; the real managers satisfy them. */
29
+ interface MailCompositionInput {
30
+ readonly configManager: { get(key: never): unknown };
31
+ readonly secretsManager: { get(key: string): Promise<string | null> };
32
+ }
33
+
34
+ /**
35
+ * Build the mail deps and the not-configured describer that go to
36
+ * `registerGatewayVerbGroups`.
37
+ *
38
+ * The settings come from the daemon's own `surfaces.email.*` keys through
39
+ * `withSurfaceEmailConfig`, so the keys an operator has already set — and that
40
+ * the settings modal now shows — keep working unchanged, and a not-configured
41
+ * answer names the keys THIS operator actually has rather than the ones the
42
+ * service validates internally.
43
+ */
44
+ export function composeMailDeps(input: MailCompositionInput): {
45
+ readonly emailServiceDeps: EmailServiceDeps;
46
+ readonly describeEmailConfigProblem: () => Promise<SurfaceEmailConfigProblem | null>;
47
+ } {
48
+ const getConfig = (key: string): unknown => input.configManager.get(key as never);
49
+ const emailServiceDeps = withSurfaceEmailConfig({
50
+ getConfig,
51
+ secretsManager: input.secretsManager,
52
+ transport: nodeEmailTransport,
53
+ // The daemon has no wording of its own for a sender line, so it takes the
54
+ // platform's rather than growing a second implementation of a rule that is
55
+ // security-relevant: a From: header is a claim, sender authentication
56
+ // raises display confidence only, and commandAuthority is the literal
57
+ // 'none'.
58
+ describeSenderClaim: describeSenderClaimNeutrally,
59
+ } as never);
60
+ return {
61
+ emailServiceDeps,
62
+ describeEmailConfigProblem: () =>
63
+ describeSurfaceEmailConfigProblem(getConfig, input.secretsManager),
64
+ };
65
+ }
@@ -11,6 +11,8 @@ import { StepUpService } from '@pellux/goodvibes-sdk/daemon';
11
11
  import { PairingTokenManager } from '@pellux/goodvibes-sdk/platform/pairing';
12
12
  import { resolvePairingWebOrigin } from '../core/pairing-origin.ts';
13
13
  import { attachWsOnlyGatewayVerbHandlers } from '@pellux/goodvibes-terminal-shell';
14
+ import { composeMailDeps } from './mail-composition.ts';
15
+ import { composeCredentialServices } from './credential-composition.ts';
14
16
  import { createDisposalScope, registerSurfaceRuntimePollers } from './disposal-wiring.ts';
15
17
  import { WatcherRegistry } from '@pellux/goodvibes-sdk/platform/watchers';
16
18
  import { ArtifactStore } from '@pellux/goodvibes-sdk/platform/artifacts';
@@ -85,6 +87,11 @@ export interface RuntimeServicesOptions {
85
87
  readonly getConversationTitle?: () => string | undefined;
86
88
  readonly workingDir: string;
87
89
  readonly homeDirectory: string;
90
+ /** Daemon state root (`--daemon-home`); absent ⇒ `<homeDirectory>/.goodvibes/daemon`.
91
+ * Threaded into SecretsManager so the override MOVES the credential store —
92
+ * without it an "isolated" daemon read the real home's secrets. One name for
93
+ * one thing; resolveGoodVibesHomeOwnership is the single reader. */
94
+ readonly daemonHomeDirectory?: string | undefined;
88
95
  /** Opt-in (daemon-side only): fold host-observed external coding-agent sessions
89
96
  * into the fleet as 'observed-external' rows. Interactive leaves it off and reads
90
97
  * the daemon snapshot. Mirrors the SDK's own createRuntimeServices option. */
@@ -292,14 +299,12 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
292
299
  runtimeBus: options.runtimeBus,
293
300
  featureFlags,
294
301
  });
295
- const secretsManager = new SecretsManager({
296
- projectRoot: workingDirectory,
297
- globalHome: homeDirectory,
298
- configManager,
302
+ // The credential/identity seam (credential-composition.ts).
303
+ const { secretsManager, stepUpService, pairingTokens } = composeCredentialServices({
304
+ workingDirectory, homeDirectory, configManager,
305
+ daemonHomeDirectory: options.daemonHomeDirectory,
306
+ pairingTokenPath: shellPaths.resolveUserPath('control-plane', 'pairing-tokens.json'),
299
307
  });
300
- // Step-up (WebAuthn) ceremony service, shared between the ceremony gateway verbs and the relay gate's verifier.
301
- const stepUpService = new StepUpService({ secrets: secretsManager });
302
- const pairingTokens = new PairingTokenManager(shellPaths.resolveUserPath('control-plane', 'pairing-tokens.json'));
303
308
  const subscriptionManager = new SubscriptionManager(shellPaths.resolveUserPath('tui', 'subscriptions.json'));
304
309
  const serviceRegistry = new ServiceRegistry(shellPaths.resolveProjectPath('tui', 'services.json'), {
305
310
  secretsManager,
@@ -603,25 +608,17 @@ export function createRuntimeServices(options: RuntimeServicesOptions): RuntimeS
603
608
  const { voiceSetup } = wireVoiceSetup({ configManager, shellPaths, voiceProviders, admitExpensiveWork });
604
609
 
605
610
  // Terminal-shell wrapper over the SDK registerGatewayVerbGroups (gateway-verbs.ts); checkin.*/fleet-needs-input/pairing.* register only when their deps are present. memoryGovernor lights up ops.memory.get; voiceSetup lights up voice.local.status/install.
606
- attachWsOnlyGatewayVerbHandlers(gatewayMethods, { processRegistry, workspaceCheckpointManager, conversationRewindPort: createSessionConversationRewindPort(), sessionBroker, secretsManager, stepUpService, approvalBroker, requestApproval: (input) => approvalBroker.requestApproval(input), watcherRegistry, userPermissionRuleStore, shellPaths, configManager, runtimeStore: options.runtimeStore, channelDeliveryRouter, providerRegistry, automationManager, sessionLister: sessionBroker, sessionIntake: sessionBroker, workingDirectory, memoryRegistry, pairingTokens, sessionLiveTurnControls, powerManager, memoryGovernor, voiceSetup, relayAvailable: () => configManager.get('relay.enabled') === true, pairingWebOrigin: () => resolvePairingWebOrigin(configManager).origin, disposal: disposalScope.registry, ...wireFleetNeedsInputPush({ registry: processRegistry, runtimeBus: options.runtimeBus, sessionBroker }) });
611
+ // calendar.*/email.* are platform-served; these two let it register (mail-composition.ts).
612
+ const { emailServiceDeps, describeEmailConfigProblem } = composeMailDeps({ configManager, secretsManager });
613
+ attachWsOnlyGatewayVerbHandlers(gatewayMethods, { homeDirectory, emailServiceDeps, describeEmailConfigProblem, processRegistry, workspaceCheckpointManager, conversationRewindPort: createSessionConversationRewindPort(), sessionBroker, secretsManager, stepUpService, approvalBroker, requestApproval: (input) => approvalBroker.requestApproval(input), watcherRegistry, userPermissionRuleStore, shellPaths, configManager, runtimeStore: options.runtimeStore, channelDeliveryRouter, providerRegistry, automationManager, sessionLister: sessionBroker, sessionIntake: sessionBroker, workingDirectory, memoryRegistry, pairingTokens, sessionLiveTurnControls, powerManager, memoryGovernor, voiceSetup, relayAvailable: () => configManager.get('relay.enabled') === true, pairingWebOrigin: () => resolvePairingWebOrigin(configManager).origin, disposal: disposalScope.registry, ...wireFleetNeedsInputPush({ registry: processRegistry, runtimeBus: options.runtimeBus, sessionBroker }) });
614
+ // surface-scoped: continuity's recovery-file check must read the SAME paths
615
+ // the app writes with, not the unscoped legacy pair.
607
616
  const integrationHelpers = new IntegrationHelperService({
608
- surface, // surface-scoped: continuity's recovery-file check must read the SAME paths the app writes with, not the unscoped legacy pair.
609
- runtimeStore: options.runtimeStore,
610
- runtimeBus: options.runtimeBus,
611
- configManager,
617
+ surface, configManager, automationManager, approvalBroker, sessionBroker, distributedRuntime,
618
+ remoteRunnerRegistry, remoteSupervisor, panelManager, localUserAuthManager, providerRegistry,
619
+ serviceRegistry, subscriptionManager, secretsManager,
620
+ runtimeStore: options.runtimeStore, runtimeBus: options.runtimeBus,
612
621
  getConversationTitle: options.getConversationTitle,
613
- automationManager,
614
- approvalBroker,
615
- sessionBroker,
616
- distributedRuntime,
617
- remoteRunnerRegistry,
618
- remoteSupervisor,
619
- panelManager,
620
- localUserAuthManager,
621
- providerRegistry,
622
- serviceRegistry,
623
- subscriptionManager,
624
- secretsManager,
625
622
  });
626
623
  // A loopback fetch that isn't allow-listed asks once through the approval
627
624
  // broker; "allow for this project" persists and later fetches never ask. Built
@@ -394,6 +394,13 @@ export function wireShellUiOpeners(options: WireShellUiOpenersOptions): void {
394
394
  commandContext.openSettingsModal = (target?: string) => {
395
395
  input.modalOpened('settings');
396
396
  input.settingsModal.open(configManager, featureFlags, subscriptionManager, serviceRegistry, mcpRegistry, secretsManager, {
397
+ // The Connections category asks the daemon handlers directly, through the
398
+ // same in-process catalog `/mail` and `/calendar` use, so the workspace
399
+ // reports the daemon's state rather than a local guess about it.
400
+ ...(commandContext.workspace?.gatewayMethods
401
+ ? { gatewayMethods: commandContext.workspace.gatewayMethods }
402
+ : {}),
403
+ requestRender: render,
397
404
  onSettingApplied: (change) => {
398
405
  // forced dark/light applies immediately (rebuild palettes + full
399
406
  // repaint); auto only re-probes at startup, so it takes effect next launch.
@@ -48,6 +48,63 @@ const SETTINGS_BEHAVIOR_BASELINE = 184;
48
48
  * `src/test/verification/feature-knob-settings-persistence.test.ts`. They are
49
49
  * therefore genuinely behavior-verified locally and counted as such here.
50
50
  */
51
+ /**
52
+ * The daemon's own mailbox and calendar keys (`surfaces.email.*`,
53
+ * `surfaces.calendar.*`), promoted into CONFIG_SCHEMA so the settings modal —
54
+ * which renders from that schema — can actually show them. Before, the
55
+ * daemon's handlers named these keys in their own error messages
56
+ * ("Set surfaces.calendar.caldavUrl and surfaces.calendar.caldavUser") while
57
+ * the UI that told an operator to set them could not display one of them.
58
+ *
59
+ * Declaring 25 keys raised the settings inventory `total` with no matching
60
+ * behavior coverage, which dropped `localBehaviorPercent` below its floor —
61
+ * the same arrival shape as the two lists above.
62
+ *
63
+ * THIS CONSTANT IS NOT A DIAL. Every key below is counted because
64
+ * `src/test/verification/daemon-mailbox-settings-persistence.test.ts` runs the
65
+ * same four-part persistence contract the other counted sets are counted for:
66
+ * schema default exposure, `set()` through the key's own validator to disk,
67
+ * reload into a fresh ConfigManager with read-back equality, and
68
+ * reset-to-default that also survives reload. That test also asserts this list
69
+ * is exactly the `surfaces.email.*` + `surfaces.calendar.*` schema key set and
70
+ * overlaps neither other counted set, so nothing is double-counted and nothing
71
+ * drifts in uncounted.
72
+ *
73
+ * PER-KEY EVIDENCE. All 25 are read by a LIVE consumer, which is why they were
74
+ * declared at all: the SDK's mail and calendar gateway compositions
75
+ * (platform/email/surface-config.ts and platform/calendar/caldav-gateway-config.ts)
76
+ * resolve every one of them when the daemon serves `email.*` and `calendar.*`.
77
+ * The three password keys additionally resolve through the daemon secret tier
78
+ * rather than from config, and carry no secret value in config themselves.
79
+ */
80
+ export const DAEMON_MAILBOX_LOCAL_SETTINGS = [
81
+ 'surfaces.email.host',
82
+ 'surfaces.email.user',
83
+ 'surfaces.email.username',
84
+ 'surfaces.email.from',
85
+ 'surfaces.email.password',
86
+ 'surfaces.email.imapHost',
87
+ 'surfaces.email.imapPort',
88
+ 'surfaces.email.imapUser',
89
+ 'surfaces.email.imapPassword',
90
+ 'surfaces.email.imap.host',
91
+ 'surfaces.email.imap.port',
92
+ 'surfaces.email.imap.user',
93
+ 'surfaces.email.imap.password',
94
+ 'surfaces.email.imap.secure',
95
+ 'surfaces.email.imap.mailbox',
96
+ 'surfaces.email.imap.draftsMailbox',
97
+ 'surfaces.email.smtp.host',
98
+ 'surfaces.email.smtp.port',
99
+ 'surfaces.email.smtp.password',
100
+ 'surfaces.email.smtp.secure',
101
+ 'surfaces.calendar.caldavUrl',
102
+ 'surfaces.calendar.caldavUser',
103
+ 'surfaces.calendar.caldavPassword',
104
+ 'surfaces.calendar.defaultCalendarId',
105
+ 'surfaces.calendar.calendars',
106
+ ] as const;
107
+
51
108
  export const FEATURE_KNOB_LOCAL_SETTINGS = [
52
109
  'provider.optimizerMode',
53
110
  'provider.optimizerPinnedModel',
@@ -247,6 +304,7 @@ const SETTINGS_BEHAVIOR_VERIFIED = SETTINGS_BEHAVIOR_BASELINE
247
304
  + FEATURE_KNOB_LOCAL_SETTINGS.length
248
305
  + DEVICE_AND_TRIGGER_LOCAL_SETTINGS.length
249
306
  + CLUSTER_GROUP_LOCAL_SETTINGS.length
307
+ + DAEMON_MAILBOX_LOCAL_SETTINGS.length
250
308
  + ENABLEMENT_KEYS_BEHAVIOR_VERIFIED;
251
309
 
252
310
  const EXTERNAL_SLASH_COMMANDS = new Set([
package/src/version.ts CHANGED
@@ -6,7 +6,7 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '1.24.1';
9
+ let _version = '1.25.0';
10
10
  try {
11
11
  const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
12
  // Only trust a version read from OUR OWN package.json. A Bun single-file