@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
@@ -0,0 +1,245 @@
1
+ /**
2
+ * calendar-runtime.ts — `/calendar`, the terminal surface over the daemon's
3
+ * calendar capability.
4
+ *
5
+ * As with `/mail`, no calendar logic lives here. CalDAV, ICS parsing, settings
6
+ * resolution and the confirmation gate are all in
7
+ * `src/daemon/handlers/calendar/`; this file parses arguments, renders results,
8
+ * and refuses honestly when the capability is not set up.
9
+ *
10
+ * ## What is exposed, and what is not
11
+ *
12
+ * `list` (the agenda) and `get` are exposed: knowing what is next without
13
+ * leaving the terminal is the whole value of a calendar on this surface.
14
+ * `create` is exposed behind the same preview-then-`--confirm` step `/mail
15
+ * send` uses, because the daemon gates it on both `body.confirm` and an
16
+ * explicit user request, and because an event created by a typo is a real
17
+ * nuisance to undo.
18
+ *
19
+ * `calendar.ics.import` and `calendar.ics.export` are deliberately NOT given a
20
+ * command. They are bulk file-level transfers of a whole calendar — an
21
+ * operation whose natural home is a file path and a scheduled job, not a line
22
+ * typed into a coding session. They stay available to any surface through the
23
+ * same catalog; this product simply does not claim to be the right place for
24
+ * them. That is a scoping decision, recorded here so it reads as a choice
25
+ * rather than an omission.
26
+ */
27
+
28
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
29
+ import {
30
+ EXPLICIT_WRITE_INVOCATION_CONTEXT,
31
+ READ_INVOCATION_CONTEXT,
32
+ errorText,
33
+ probeConnection,
34
+ renderConnectionStatus,
35
+ } from './connection-status.ts';
36
+
37
+ /** Agenda item, exactly the daemon's `calendar.events.list` item shape. */
38
+ interface CalendarEvent {
39
+ readonly id: string;
40
+ readonly title: string;
41
+ readonly start: string;
42
+ readonly end: string;
43
+ readonly location?: string;
44
+ readonly description?: string;
45
+ readonly attendees?: readonly string[];
46
+ }
47
+
48
+ const DEFAULT_LIST_LIMIT = 20;
49
+ const MAX_LIST_LIMIT = 200;
50
+
51
+ /**
52
+ * Render a timestamp for a terminal agenda.
53
+ *
54
+ * An unparseable value is printed verbatim rather than replaced with a
55
+ * placeholder — showing what the server actually returned is more useful than
56
+ * hiding it behind "invalid date".
57
+ */
58
+ export function formatWhen(raw: string): string {
59
+ const parsed = new Date(raw);
60
+ if (Number.isNaN(parsed.getTime())) return raw;
61
+ const date = parsed.toISOString().slice(0, 10);
62
+ const time = parsed.toISOString().slice(11, 16);
63
+ return `${date} ${time}`;
64
+ }
65
+
66
+ function truncate(value: string, width: number): string {
67
+ const flat = value.replace(/\s+/g, ' ').trim();
68
+ return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`;
69
+ }
70
+
71
+ export function renderAgenda(events: readonly CalendarEvent[]): string {
72
+ if (events.length === 0) return 'No events in this window.';
73
+ const lines = [`Agenda — ${events.length} event${events.length === 1 ? '' : 's'}:`];
74
+ for (const event of events) {
75
+ const where = event.location ? ` @ ${truncate(event.location, 24)}` : '';
76
+ lines.push(` ${formatWhen(event.start)} ${truncate(event.title, 44).padEnd(44)}${where}`);
77
+ }
78
+ lines.push('', ' /calendar get <id> — open one event');
79
+ return lines.join('\n');
80
+ }
81
+
82
+ export function renderEvent(event: CalendarEvent): string {
83
+ const lines = [
84
+ `Title: ${event.title}`,
85
+ `Start: ${formatWhen(event.start)}`,
86
+ `End: ${formatWhen(event.end)}`,
87
+ ];
88
+ if (event.location) lines.push(`Where: ${event.location}`);
89
+ const attendees = event.attendees ?? [];
90
+ if (attendees.length > 0) lines.push(`With: ${attendees.join(', ')}`);
91
+ if (event.description && event.description.trim().length > 0) {
92
+ lines.push('', event.description.trimEnd());
93
+ }
94
+ return lines.join('\n');
95
+ }
96
+
97
+ /**
98
+ * Split `title | start | end` on unescaped pipes — same separator rule as
99
+ * `/mail`, for the same reason: titles contain spaces and per-command quoting
100
+ * conventions are their own bug source.
101
+ */
102
+ export function parseEventArgs(raw: string): { title: string; start: string; end: string } | null {
103
+ const parts = raw.split('|').map((part) => part.trim());
104
+ if (parts.length !== 3) return null;
105
+ const [title, start, end] = parts;
106
+ if (!title || !start || !end) return null;
107
+ return { title, start, end };
108
+ }
109
+
110
+ function parseLimit(raw: string | undefined): number {
111
+ const parsed = Number(raw);
112
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIST_LIMIT;
113
+ return Math.min(MAX_LIST_LIMIT, Math.floor(parsed));
114
+ }
115
+
116
+ const USAGE = [
117
+ 'Usage:',
118
+ ' /calendar connection status and the agenda ahead',
119
+ ' /calendar status connection status only',
120
+ ' /calendar list [n] the next n events (default 20)',
121
+ ' /calendar get <id> open one event',
122
+ ' /calendar create <title | start | end> [--confirm] preview, then create',
123
+ '',
124
+ ' Times are ISO-8601, e.g. 2026-08-01T14:00:00Z',
125
+ ].join('\n');
126
+
127
+ export function registerCalendarRuntimeCommands(registry: CommandRegistry): void {
128
+ registry.register({
129
+ name: 'calendar',
130
+ aliases: ['cal'],
131
+ description: 'Calendar over the daemon: connection status, agenda, event detail, create',
132
+ usage: '[status|list|get|create] …',
133
+ argsHint: '[status|list <n>|get <id>|create]',
134
+ async handler(args, ctx) {
135
+ const gateway = ctx.workspace.gatewayMethods;
136
+ const sub = (args[0] ?? '').toLowerCase();
137
+
138
+ if (sub === 'status') {
139
+ ctx.print(renderConnectionStatus(await probeConnection(gateway, 'calendar')));
140
+ return;
141
+ }
142
+
143
+ if (!gateway) {
144
+ ctx.print(renderConnectionStatus(await probeConnection(undefined, 'calendar')));
145
+ return;
146
+ }
147
+
148
+ if (sub === '' || sub === 'list') {
149
+ await listEvents(ctx, gateway, parseLimit(args[1]), sub === '');
150
+ return;
151
+ }
152
+ if (sub === 'get') {
153
+ await getEvent(ctx, gateway, args[1]);
154
+ return;
155
+ }
156
+ if (sub === 'create') {
157
+ await createEvent(ctx, gateway, args.slice(1));
158
+ return;
159
+ }
160
+ ctx.print(USAGE);
161
+ },
162
+ });
163
+ }
164
+
165
+ type Gateway = NonNullable<CommandContext['workspace']['gatewayMethods']>;
166
+
167
+ async function withStatusOnFailure(
168
+ ctx: CommandContext,
169
+ gateway: Gateway,
170
+ label: string,
171
+ run: () => Promise<void>,
172
+ ): Promise<void> {
173
+ try {
174
+ await run();
175
+ } catch (error) {
176
+ ctx.print(`[calendar ${label}] ${errorText(error)}`);
177
+ ctx.print(renderConnectionStatus(await probeConnection(gateway, 'calendar')));
178
+ }
179
+ }
180
+
181
+ async function listEvents(ctx: CommandContext, gateway: Gateway, limit: number, withStatus: boolean): Promise<void> {
182
+ await withStatusOnFailure(ctx, gateway, 'list', async () => {
183
+ const result = await gateway.invoke('calendar.events.list', {
184
+ ...READ_INVOCATION_CONTEXT,
185
+ body: { limit, from: new Date().toISOString() },
186
+ }) as { readonly events?: readonly CalendarEvent[] };
187
+ if (withStatus) {
188
+ ctx.print(renderConnectionStatus({
189
+ surface: 'calendar',
190
+ state: 'ready',
191
+ detail: 'Calendar is connected — the daemon reached the server and returned a result.',
192
+ nextActions: [],
193
+ }));
194
+ }
195
+ ctx.print(renderAgenda(result.events ?? []));
196
+ });
197
+ }
198
+
199
+ async function getEvent(ctx: CommandContext, gateway: Gateway, eventId: string | undefined): Promise<void> {
200
+ if (!eventId) {
201
+ ctx.print('Usage: /calendar get <id> — the id from /calendar list.');
202
+ return;
203
+ }
204
+ await withStatusOnFailure(ctx, gateway, 'get', async () => {
205
+ const event = await gateway.invoke('calendar.events.get', {
206
+ ...READ_INVOCATION_CONTEXT,
207
+ body: { eventId },
208
+ }) as CalendarEvent;
209
+ ctx.print(renderEvent(event));
210
+ });
211
+ }
212
+
213
+ async function createEvent(ctx: CommandContext, gateway: Gateway, rest: readonly string[]): Promise<void> {
214
+ const raw = rest.filter((part) => part !== '--confirm').join(' ');
215
+ const confirmed = rest.includes('--confirm');
216
+ const parsed = parseEventArgs(raw);
217
+ if (parsed === null) {
218
+ ctx.print([
219
+ 'Usage: /calendar create <title | start | end>',
220
+ ' Three fields separated by | — for example:',
221
+ ' /calendar create Design review | 2026-08-01T14:00:00Z | 2026-08-01T15:00:00Z',
222
+ ].join('\n'));
223
+ return;
224
+ }
225
+
226
+ if (!confirmed) {
227
+ ctx.print([
228
+ 'This would create:',
229
+ ` Title: ${parsed.title}`,
230
+ ` Start: ${formatWhen(parsed.start)}`,
231
+ ` End: ${formatWhen(parsed.end)}`,
232
+ '',
233
+ 'Nothing has been created. Re-run the same line with --confirm to create it.',
234
+ ].join('\n'));
235
+ return;
236
+ }
237
+
238
+ await withStatusOnFailure(ctx, gateway, 'create', async () => {
239
+ const result = await gateway.invoke('calendar.events.create', {
240
+ ...EXPLICIT_WRITE_INVOCATION_CONTEXT,
241
+ body: { title: parsed.title, start: parsed.start, end: parsed.end, confirm: true },
242
+ }) as { readonly eventId: string; readonly createdAt: string };
243
+ ctx.print(`Created "${parsed.title}" (event id ${result.eventId}, at ${result.createdAt}).`);
244
+ });
245
+ }
@@ -1,6 +1,13 @@
1
- import type { CommandRegistry } from '../command-registry.ts';
1
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
2
2
  import type { ConfigKey } from '@pellux/goodvibes-sdk/platform/config';
3
3
  import { setActiveThemeMode, type ThemeMode } from '../../renderer/theme.ts';
4
+ import {
5
+ buildGoodVibesSecretKey,
6
+ defaultSecretBackedScope,
7
+ isSecretConfigKey,
8
+ persistSecretBackedConfigValue,
9
+ } from '../../config/secret-config.ts';
10
+ import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
4
11
 
5
12
  export function registerConfigCommand(registry: CommandRegistry): void {
6
13
  registry.register({
@@ -9,7 +16,7 @@ export function registerConfigCommand(registry: CommandRegistry): void {
9
16
  description: 'Open the fullscreen configuration workspace, or set a key directly',
10
17
  usage: '[category|key] | set <key> <value>',
11
18
  argsHint: '[category|key] | set <key> <value>',
12
- handler(args, ctx) {
19
+ async handler(args, ctx) {
13
20
  // An earlier replay fix: `/config set <key> <value>` used to fall through to
14
21
  // the workspace with the assignment silently ignored — the dishonest-
15
22
  // fallthrough class. `set` is now a real verb.
@@ -20,6 +27,17 @@ export function registerConfigCommand(registry: CommandRegistry): void {
20
27
  ctx.print('Usage: /config set <key> <value> — e.g. /config set display.themeMode light');
21
28
  return;
22
29
  }
30
+ // A credential key must never take the generic path below: setDynamic
31
+ // writes the value it is handed straight into a settings JSON file, so
32
+ // `/config set surfaces.email.password hunter2` used to put the mailbox
33
+ // password in cleartext on disk. The settings modal already routes these
34
+ // keys through the secret manager (settings-modal-secrets.ts); this
35
+ // command has to route them the same way, or the two ways of setting the
36
+ // same key disagree about where the value lands.
37
+ if (isSecretConfigKey(key)) {
38
+ await handleSecretConfigSet(ctx, key as ConfigKey, value);
39
+ return;
40
+ }
23
41
  try {
24
42
  const before = ctx.platform.configManager.get(key as ConfigKey);
25
43
  ctx.platform.configManager.setDynamic(key as ConfigKey, coerceConfigValue(value));
@@ -49,6 +67,54 @@ export function registerConfigCommand(registry: CommandRegistry): void {
49
67
  });
50
68
  }
51
69
 
70
+ /**
71
+ * Store a credential-bearing config key through the secret manager instead of
72
+ * writing it into a config file: the value goes to the secret store and the
73
+ * config key keeps only a `goodvibes://secrets/...` reference to it.
74
+ *
75
+ * The entered value is never echoed. The transcript is a diagnostic surface —
76
+ * it is scrolled back through, copied into support threads and captured in
77
+ * screen recordings — so it gets the reference and the store key, never the
78
+ * secret.
79
+ *
80
+ * With no secret manager in this runtime there is nowhere safe to put it, and
81
+ * the command REFUSES rather than falling back to a plaintext config write.
82
+ */
83
+ async function handleSecretConfigSet(
84
+ ctx: CommandContext,
85
+ key: ConfigKey,
86
+ value: string,
87
+ ): Promise<void> {
88
+ const secretKey = buildGoodVibesSecretKey(key);
89
+ const secretsManager = ctx.platform?.secretsManager;
90
+ if (!secretsManager) {
91
+ ctx.print(
92
+ `${key} holds a credential, and this runtime has no secret store to put it in — `
93
+ + `refusing rather than writing it in cleartext into a config file.\n`
94
+ + ` Store it with: /secrets set ${secretKey} <value>`,
95
+ );
96
+ return;
97
+ }
98
+ try {
99
+ const configValue = await persistSecretBackedConfigValue(
100
+ ctx.platform.configManager,
101
+ secretsManager,
102
+ key,
103
+ value,
104
+ );
105
+ const scope = defaultSecretBackedScope(key);
106
+ ctx.print(
107
+ `Set ${key}: stored in the ${scope} secret tier as ${secretKey} (value hidden).\n`
108
+ + ` ${key} now holds the reference ${configValue} — the value itself is never written to a config file.`
109
+ + (scope === 'daemon'
110
+ ? '\n The daemon reads that tier, so this keeps working with this client closed.'
111
+ : ''),
112
+ );
113
+ } catch (e) {
114
+ ctx.print(`Could not set ${key}: ${summarizeError(e)}`);
115
+ }
116
+ }
117
+
52
118
  /** Coerce CLI text to the JSON-ish scalar the config schema expects. */
53
119
  function coerceConfigValue(raw: string): unknown {
54
120
  if (raw === 'true') return true;
@@ -0,0 +1,258 @@
1
+ /**
2
+ * connection-status.ts — honest connection state for the mail and calendar
3
+ * capabilities, derived from the daemon's own answer rather than re-decided
4
+ * here.
5
+ *
6
+ * The daemon owns mail (IMAP/SMTP) and calendar (CalDAV/ICS) end to end: the
7
+ * settings resolution, the credential read, the connectors, the confirmation
8
+ * posture (see src/daemon/handlers/email and src/daemon/handlers/calendar).
9
+ * The TUI holds no second copy of any of it. So "is mail connected?" is not a
10
+ * question this file answers from config — it is a question it ASKS, by
11
+ * invoking the cheapest read verb and reading the failure code that comes back:
12
+ *
13
+ * EMAIL_NOT_CONFIGURED host/user are missing
14
+ * EMAIL_CREDENTIALS_MISSING host/user are set, the password secret is not
15
+ * CALENDAR_NOT_CONFIGURED CalDAV url/user are missing
16
+ *
17
+ * Those codes are the daemon's, and the next step each one implies is the
18
+ * daemon's requirement, not a guess here. If the daemon changes what it needs,
19
+ * this surface follows, because it never encoded the rule.
20
+ *
21
+ * ## Why the in-process catalog and not the operator HTTP wire
22
+ *
23
+ * Every `email.*` / `calendar.*` descriptor the SDK catalogs carries
24
+ * `invokable: false`. On the generic HTTP/WS dispatch path that flag is checked
25
+ * by `validateGatewayInvocation` BEFORE any handler lookup and answered with a
26
+ * flat 400 `NOT_INVOKABLE` — so `sdk.operator.invoke('email.inbox.list', …)`
27
+ * cannot reach these verbs no matter what is configured, and a surface built on
28
+ * it would be a dead button.
29
+ *
30
+ * `GatewayMethodCatalog.invoke()` deliberately does NOT consult that flag: a
31
+ * runtime that has registered a real handler is authoritative over whether its
32
+ * own method works. This product registers those handlers into its own catalog
33
+ * at composition time (`createDaemonHandlerComposition`, called by the shared
34
+ * `createRuntimeServices` root that both the TUI and the daemon boot from), so
35
+ * the in-process catalog is a live, correct route to the same code — the same
36
+ * seam `/review` already uses for `checkpoints.*`. That is what these surfaces
37
+ * call.
38
+ *
39
+ * Nothing here reads, holds, or renders a credential. The probe asks for one
40
+ * item and discards it; only presence or absence of a failure reaches the UI.
41
+ */
42
+
43
+ import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
44
+
45
+ export type ConnectionSurface = 'mail' | 'calendar';
46
+
47
+ /**
48
+ * What a surface can be.
49
+ *
50
+ * `checking` exists because the probe is real I/O and the settings workspace
51
+ * renders synchronously — a row has to say something true while the answer is
52
+ * in flight, and "checking" is true where "not configured" would be invented.
53
+ */
54
+ export type ConnectionState = 'ready' | 'needs-setup' | 'unreachable' | 'checking';
55
+
56
+ export interface ConnectionStatus {
57
+ readonly surface: ConnectionSurface;
58
+ readonly state: ConnectionState;
59
+ /** One line stating what is actually true right now. Never a credential. */
60
+ readonly detail: string;
61
+ /** Concrete next steps. Empty only when the state is `ready`. */
62
+ readonly nextActions: readonly string[];
63
+ }
64
+
65
+ /** Display label for a surface. */
66
+ export function connectionSurfaceLabel(surface: ConnectionSurface): string {
67
+ return surface === 'mail' ? 'Mail' : 'Calendar';
68
+ }
69
+
70
+ /** The read verb each surface is probed with — the cheapest read it has. */
71
+ export const CONNECTION_PROBE_METHOD = {
72
+ mail: 'email.inbox.list',
73
+ calendar: 'calendar.events.list',
74
+ } as const;
75
+
76
+ /**
77
+ * The setup steps each daemon failure code implies.
78
+ *
79
+ * The `surfaces.*` settings are daemon-owned by prefix (see the SDK's
80
+ * `config-ownership`), so `/config set` on them lands in the daemon's own
81
+ * store and keeps working with this client closed — which is the requirement.
82
+ *
83
+ * The PASSWORDS now have it too, and these steps changed the round the platform
84
+ * gave it to them. A credential is filed in the daemon's tier only when a
85
+ * daemon-owned config path declares it, and the two keys this product's daemon
86
+ * reads — `surfaces.email.password` and `surfaces.calendar.caldavPassword` —
87
+ * were declared in neither `CONFIG_SCHEMA` nor the platform's non-schema
88
+ * daemon-owned path list. `isDaemonOwnedSecretKey()` answered false for both,
89
+ * so a `/secrets set` filed them in THIS client's store where the daemon never
90
+ * looks, and telling anyone to run it would have been handing them a step that
91
+ * silently did nothing. The instruction was the daemon's own environment
92
+ * instead, because that genuinely reached it.
93
+ *
94
+ * The platform release that began serving `email.*` and `calendar.*` declares
95
+ * the whole mail and CalDAV connection as daemon-owned, so both keys answer
96
+ * true now and a write carrying an explicit scope is RELOCATED to the daemon
97
+ * tier rather than filed where it was asked for. `/secrets set` is therefore the
98
+ * step that works, and it is the better one: it needs no restart and no shell
99
+ * access to the machine the daemon runs on. The environment still resolves
100
+ * first, so an operator who already set it there is not broken by this.
101
+ */
102
+ const DAEMON_TIER_NOTE =
103
+ 'The password reaches the DAEMON: a daemon-owned credential is filed in its tier '
104
+ + 'no matter which surface stores it, so this keeps working with this client closed.';
105
+
106
+ const SETUP_STEPS: Readonly<Record<string, readonly string[]>> = {
107
+ EMAIL_NOT_CONFIGURED: [
108
+ '/config set surfaces.email.host imap.example.com',
109
+ '/config set surfaces.email.user you@example.com',
110
+ `/secrets set GOODVIBES_SURFACES_EMAIL_PASSWORD <password>. ${DAEMON_TIER_NOTE}`,
111
+ ],
112
+ EMAIL_CREDENTIALS_MISSING: [
113
+ `/secrets set GOODVIBES_SURFACES_EMAIL_PASSWORD <password>. ${DAEMON_TIER_NOTE}`,
114
+ ],
115
+ CALENDAR_NOT_CONFIGURED: [
116
+ '/config set surfaces.calendar.caldavUrl https://example.com/dav/',
117
+ '/config set surfaces.calendar.caldavUser you@example.com',
118
+ `/secrets set GOODVIBES_SURFACES_CALENDAR_CALDAV_PASSWORD <password>. ${DAEMON_TIER_NOTE}`,
119
+ ],
120
+ };
121
+
122
+ /** Message text off an unknown thrown value, without assuming Error. */
123
+ export function errorText(error: unknown): string {
124
+ return error instanceof Error ? error.message : String(error);
125
+ }
126
+
127
+ /**
128
+ * Pull the daemon's machine code off a thrown handler error.
129
+ *
130
+ * In-process the throw is the daemon's own `HandlerError`, which carries
131
+ * `code` directly. The structured wire body is also checked so the same
132
+ * derivation keeps working if a surface ever reaches these verbs over HTTP.
133
+ */
134
+ export function daemonErrorCode(error: unknown): string | null {
135
+ if (typeof error !== 'object' || error === null) return null;
136
+ const direct = (error as { readonly code?: unknown }).code;
137
+ if (typeof direct === 'string' && direct.length > 0) return direct;
138
+ const body = (error as { readonly body?: unknown }).body;
139
+ if (typeof body === 'object' && body !== null) {
140
+ const bodyCode = (body as { readonly code?: unknown }).code;
141
+ if (typeof bodyCode === 'string' && bodyCode.length > 0) return bodyCode;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /**
147
+ * Turn a probe outcome into a status. Pure — the caller does the I/O, so every
148
+ * daemon answer is exercised in tests without a daemon.
149
+ *
150
+ * `error === null` means the read verb returned. That is the only evidence
151
+ * that counts as ready: settings resolved, credential read, server reached.
152
+ */
153
+ export function describeConnectionProbe(
154
+ surface: ConnectionSurface,
155
+ error: unknown,
156
+ ): ConnectionStatus {
157
+ const label = connectionSurfaceLabel(surface);
158
+
159
+ if (error === null) {
160
+ return {
161
+ surface,
162
+ state: 'ready',
163
+ detail: `${label} is connected — the daemon reached the server and returned a result.`,
164
+ nextActions: [],
165
+ };
166
+ }
167
+
168
+ const code = daemonErrorCode(error);
169
+ const steps = code === null ? undefined : SETUP_STEPS[code];
170
+ if (steps !== undefined) {
171
+ return {
172
+ surface,
173
+ state: 'needs-setup',
174
+ detail: `${label} is not set up yet: ${errorText(error)}`,
175
+ nextActions: steps,
176
+ };
177
+ }
178
+
179
+ // A missing descriptor is a real answer and a different problem from "not
180
+ // configured". Saying "not set up" here would send the owner off to set
181
+ // config keys that would change nothing.
182
+ if (code === 'METHOD_NOT_FOUND' || code === 'NOT_INVOKABLE') {
183
+ return {
184
+ surface,
185
+ state: 'unreachable',
186
+ detail: `This build does not serve ${CONNECTION_PROBE_METHOD[surface]} (${code}).`,
187
+ nextActions: [`Update to a build whose daemon serves the ${surface} methods.`],
188
+ };
189
+ }
190
+
191
+ return {
192
+ surface,
193
+ state: 'unreachable',
194
+ detail: `${label} could not be checked: ${errorText(error)}`,
195
+ nextActions: ['Check that the runtime composed its daemon handlers: /doctor'],
196
+ };
197
+ }
198
+
199
+ /** The status for a surface when the gateway catalog is not wired at all. */
200
+ export function unwiredConnectionStatus(surface: ConnectionSurface): ConnectionStatus {
201
+ return {
202
+ surface,
203
+ state: 'unreachable',
204
+ detail: `${connectionSurfaceLabel(surface)} runs through the gateway method catalog, which is not wired into this session.`,
205
+ nextActions: ['Start the full runtime (this surface is unavailable in a reduced harness).'],
206
+ };
207
+ }
208
+
209
+ /** The invocation envelope every read on these surfaces uses. */
210
+ export const READ_INVOCATION_CONTEXT = { context: { clientKind: 'tui' as const } };
211
+
212
+ /**
213
+ * The invocation envelope a confirmation-gated write needs.
214
+ *
215
+ * The daemon's register wrapper demands BOTH `body.confirm === true` and
216
+ * `context.metadata.explicitUserRequest === true`. The second is set only on
217
+ * paths where a person typed the command that performs the write, which is the
218
+ * whole point of the flag — it is never set on a read or on a background
219
+ * refresh.
220
+ */
221
+ export const EXPLICIT_WRITE_INVOCATION_CONTEXT = {
222
+ context: { clientKind: 'tui' as const, metadata: { explicitUserRequest: true } },
223
+ };
224
+
225
+ /**
226
+ * Probe one surface against the in-process gateway catalog.
227
+ *
228
+ * Asks for a single item — enough to prove the whole path (settings →
229
+ * credential → connector → server) without pulling a mailbox into memory.
230
+ */
231
+ export async function probeConnection(
232
+ gateway: GatewayMethodCatalog | undefined,
233
+ surface: ConnectionSurface,
234
+ ): Promise<ConnectionStatus> {
235
+ if (!gateway) return unwiredConnectionStatus(surface);
236
+ try {
237
+ await gateway.invoke(CONNECTION_PROBE_METHOD[surface], {
238
+ ...READ_INVOCATION_CONTEXT,
239
+ body: { limit: 1 },
240
+ });
241
+ return describeConnectionProbe(surface, null);
242
+ } catch (error) {
243
+ return describeConnectionProbe(surface, error);
244
+ }
245
+ }
246
+
247
+ /** Render one status as transcript lines. Used by `/mail` and `/calendar`. */
248
+ export function renderConnectionStatus(status: ConnectionStatus): string {
249
+ const lines = [
250
+ `${connectionSurfaceLabel(status.surface)}: ${status.state}`,
251
+ ` ${status.detail}`,
252
+ ];
253
+ if (status.nextActions.length > 0) {
254
+ lines.push(' Next:');
255
+ for (const action of status.nextActions) lines.push(` ${action}`);
256
+ }
257
+ return lines.join('\n');
258
+ }
@@ -130,7 +130,15 @@ export function registerLocalProviderRuntimeCommands(registry: CommandRegistry):
130
130
  }
131
131
  void (async () => {
132
132
  try {
133
- await secretsManager.set(keyName, value, { scope: 'user', medium: 'secure' });
133
+ // Daemon scope, stated explicitly here rather than left to the
134
+ // platform's daemon-owned credential registry: that registry
135
+ // recognises credentials by NAME, and this name is derived
136
+ // from an operator-chosen provider name
137
+ // (customProviderKeyName above), so no shipped list can
138
+ // contain it. The daemon is what runs the model, so the key
139
+ // has to reach the daemon tier or a locally-hosted endpoint
140
+ // is usable only while this TUI is open.
141
+ await secretsManager.set(keyName, value, { scope: 'daemon', medium: 'secure' });
134
142
  ctx.print(`Stored the API key for '${name}' (value hidden). It resolves live for this provider — no restart needed.`);
135
143
  } catch (error) {
136
144
  ctx.print(`Could not store the key for '${name}': ${summarizeError(error)}`);