@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
@@ -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
+ }
@@ -0,0 +1,267 @@
1
+ /**
2
+ * mail-runtime.ts — `/mail`, the terminal surface over the daemon's mail
3
+ * capability.
4
+ *
5
+ * This command implements no mail. Every verb here is one call into the
6
+ * gateway method catalog, where `src/daemon/handlers/email/` owns IMAP, SMTP,
7
+ * settings resolution, the credential read, and the confirmation gate. What
8
+ * lives in this file is argument parsing, rendering, and an honest refusal when
9
+ * the capability is not set up — nothing else.
10
+ *
11
+ * ## What is exposed, and what is not
12
+ *
13
+ * Reading (`list`, `read`) and drafting are exposed because checking mail
14
+ * without leaving the terminal is squarely what this product is for, and
15
+ * because a draft changes nothing outside the account's own Drafts folder.
16
+ *
17
+ * Sending is exposed too — a shipped route with no way to reach it is a route
18
+ * that is not wired up — but never in one step. `/mail send` first renders
19
+ * exactly what would leave the machine and stops; only a re-run carrying
20
+ * `--confirm` sets both flags the daemon's gate demands. That mirrors the
21
+ * preview-then-confirm shape `/review` uses for hunk reverts, and it means the
22
+ * dangerous action cannot happen from a typo or a recalled line of history.
23
+ *
24
+ * Message bodies are attacker-controlled text. They are printed as transcript
25
+ * output, which is display-only and never enters the model's message history,
26
+ * so a body cannot smuggle instructions into a turn by being read here.
27
+ */
28
+
29
+ import type { CommandContext, CommandRegistry } from '../command-registry.ts';
30
+ import {
31
+ EXPLICIT_WRITE_INVOCATION_CONTEXT,
32
+ READ_INVOCATION_CONTEXT,
33
+ errorText,
34
+ probeConnection,
35
+ renderConnectionStatus,
36
+ } from './connection-status.ts';
37
+
38
+ /** Inbox summary, exactly the daemon's `email.inbox.list` item shape. */
39
+ interface InboxMessage {
40
+ readonly uid: number;
41
+ readonly from: string;
42
+ readonly subject: string;
43
+ readonly date: string;
44
+ readonly unread: boolean;
45
+ readonly bodyPreview: string;
46
+ }
47
+
48
+ /** Full message, exactly the daemon's `email.inbox.read` output shape. */
49
+ interface FullMessage {
50
+ readonly uid: number;
51
+ readonly from: string;
52
+ readonly subject: string;
53
+ readonly date: string;
54
+ readonly bodyText?: string;
55
+ readonly attachments?: ReadonlyArray<{ readonly filename: string; readonly sizeBytes: number }>;
56
+ }
57
+
58
+ const DEFAULT_LIST_LIMIT = 15;
59
+ const MAX_LIST_LIMIT = 100;
60
+
61
+ function truncate(value: string, width: number): string {
62
+ const flat = value.replace(/\s+/g, ' ').trim();
63
+ return flat.length <= width ? flat : `${flat.slice(0, width - 1)}…`;
64
+ }
65
+
66
+ export function renderInboxList(messages: readonly InboxMessage[]): string {
67
+ if (messages.length === 0) return 'Inbox is empty (nothing matched).';
68
+ const lines = [`Inbox — ${messages.length} message${messages.length === 1 ? '' : 's'}:`];
69
+ for (const message of messages) {
70
+ const mark = message.unread ? '●' : ' ';
71
+ lines.push(` ${mark} ${String(message.uid).padStart(6)} ${truncate(message.from, 28).padEnd(28)} ${truncate(message.subject, 48)}`);
72
+ }
73
+ lines.push('', ' /mail read <uid> — open one message');
74
+ return lines.join('\n');
75
+ }
76
+
77
+ export function renderMessage(message: FullMessage): string {
78
+ const lines = [
79
+ `From: ${message.from}`,
80
+ `Subject: ${message.subject}`,
81
+ `Date: ${message.date}`,
82
+ ];
83
+ const attachments = message.attachments ?? [];
84
+ if (attachments.length > 0) {
85
+ lines.push(`Files: ${attachments.map((a) => `${a.filename} (${a.sizeBytes} bytes)`).join(', ')}`);
86
+ }
87
+ lines.push('', message.bodyText && message.bodyText.trim().length > 0
88
+ ? message.bodyText.trimEnd()
89
+ : '(no plain-text body)');
90
+ return lines.join('\n');
91
+ }
92
+
93
+ /**
94
+ * Split `to | subject | body` on unescaped pipes.
95
+ *
96
+ * A pipe rather than positional words because a subject and a body both
97
+ * contain spaces, and quoting rules invented per-command are their own bug
98
+ * source. Exactly three fields are required, so a missing one is a clear
99
+ * usage error rather than a message sent with an empty subject.
100
+ */
101
+ export function parseComposeArgs(raw: string): { to: string; subject: string; body: string } | null {
102
+ const parts = raw.split('|').map((part) => part.trim());
103
+ if (parts.length !== 3) return null;
104
+ const [to, subject, body] = parts;
105
+ if (!to || !subject || !body) return null;
106
+ return { to, subject, body };
107
+ }
108
+
109
+ function parseLimit(raw: string | undefined): number {
110
+ const parsed = Number(raw);
111
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_LIST_LIMIT;
112
+ return Math.min(MAX_LIST_LIMIT, Math.floor(parsed));
113
+ }
114
+
115
+ const USAGE = [
116
+ 'Usage:',
117
+ ' /mail connection status and the latest messages',
118
+ ' /mail status connection status only',
119
+ ' /mail list [n] list the n most recent messages (default 15)',
120
+ ' /mail read <uid> open one message',
121
+ ' /mail draft <to | subject | body> save a draft to the account',
122
+ ' /mail send <to | subject | body> [--confirm] preview, then send',
123
+ ].join('\n');
124
+
125
+ export function registerMailRuntimeCommands(registry: CommandRegistry): void {
126
+ registry.register({
127
+ name: 'mail',
128
+ aliases: ['email'],
129
+ description: 'Mail over the daemon: connection status, inbox, read, draft, send',
130
+ usage: '[status|list|read|draft|send] …',
131
+ argsHint: '[status|list <n>|read <uid>|draft|send]',
132
+ async handler(args, ctx) {
133
+ const gateway = ctx.workspace.gatewayMethods;
134
+ const sub = (args[0] ?? '').toLowerCase();
135
+
136
+ if (sub === 'status') {
137
+ ctx.print(renderConnectionStatus(await probeConnection(gateway, 'mail')));
138
+ return;
139
+ }
140
+
141
+ if (!gateway) {
142
+ ctx.print(renderConnectionStatus(await probeConnection(undefined, 'mail')));
143
+ return;
144
+ }
145
+
146
+ if (sub === '' || sub === 'list') {
147
+ await listInbox(ctx, gateway, parseLimit(args[1]), sub === '');
148
+ return;
149
+ }
150
+ if (sub === 'read') {
151
+ await readMessage(ctx, gateway, args[1]);
152
+ return;
153
+ }
154
+ if (sub === 'draft' || sub === 'send') {
155
+ await compose(ctx, gateway, sub, args.slice(1));
156
+ return;
157
+ }
158
+ ctx.print(USAGE);
159
+ },
160
+ });
161
+ }
162
+
163
+ type Gateway = NonNullable<CommandContext['workspace']['gatewayMethods']>;
164
+
165
+ /**
166
+ * Every failure path renders the connection status rather than a bare error:
167
+ * "not configured" is far more often the real cause than a transient fault,
168
+ * and the status carries the exact next step.
169
+ */
170
+ async function withStatusOnFailure(
171
+ ctx: CommandContext,
172
+ gateway: Gateway,
173
+ label: string,
174
+ run: () => Promise<void>,
175
+ ): Promise<void> {
176
+ try {
177
+ await run();
178
+ } catch (error) {
179
+ ctx.print(`[mail ${label}] ${errorText(error)}`);
180
+ ctx.print(renderConnectionStatus(await probeConnection(gateway, 'mail')));
181
+ }
182
+ }
183
+
184
+ async function listInbox(ctx: CommandContext, gateway: Gateway, limit: number, withStatus: boolean): Promise<void> {
185
+ await withStatusOnFailure(ctx, gateway, 'list', async () => {
186
+ const result = await gateway.invoke('email.inbox.list', {
187
+ ...READ_INVOCATION_CONTEXT,
188
+ body: { limit },
189
+ }) as { readonly messages?: readonly InboxMessage[] };
190
+ if (withStatus) {
191
+ ctx.print(renderConnectionStatus({
192
+ surface: 'mail',
193
+ state: 'ready',
194
+ detail: 'Mail is connected — the daemon reached the server and returned a result.',
195
+ nextActions: [],
196
+ }));
197
+ }
198
+ ctx.print(renderInboxList(result.messages ?? []));
199
+ });
200
+ }
201
+
202
+ async function readMessage(ctx: CommandContext, gateway: Gateway, rawUid: string | undefined): Promise<void> {
203
+ const uid = Number(rawUid);
204
+ if (!Number.isFinite(uid) || uid <= 0) {
205
+ ctx.print('Usage: /mail read <uid> — the uid column from /mail list.');
206
+ return;
207
+ }
208
+ await withStatusOnFailure(ctx, gateway, 'read', async () => {
209
+ const message = await gateway.invoke('email.inbox.read', {
210
+ ...READ_INVOCATION_CONTEXT,
211
+ body: { uid },
212
+ }) as FullMessage;
213
+ ctx.print(renderMessage(message));
214
+ });
215
+ }
216
+
217
+ async function compose(ctx: CommandContext, gateway: Gateway, verb: 'draft' | 'send', rest: readonly string[]): Promise<void> {
218
+ const raw = rest.filter((part) => part !== '--confirm').join(' ');
219
+ const confirmed = rest.includes('--confirm');
220
+ const parsed = parseComposeArgs(raw);
221
+ if (parsed === null) {
222
+ ctx.print([
223
+ `Usage: /mail ${verb} <to | subject | body>`,
224
+ ' Three fields separated by | — for example:',
225
+ ` /mail ${verb} me@example.com | Build is green | All gates passed.`,
226
+ ].join('\n'));
227
+ return;
228
+ }
229
+
230
+ if (verb === 'draft') {
231
+ await withStatusOnFailure(ctx, gateway, 'draft', async () => {
232
+ // No `confirm` in the body: the SDK contract for draft.create does not
233
+ // declare one (its input schema is closed), and the handler is registered
234
+ // without the confirmation wrapper. Sending one would be a field the
235
+ // schema rejects.
236
+ const result = await gateway.invoke('email.draft.create', {
237
+ ...EXPLICIT_WRITE_INVOCATION_CONTEXT,
238
+ body: { to: parsed.to, subject: parsed.subject, body: parsed.body },
239
+ }) as { readonly uid: number; readonly draftId: string };
240
+ ctx.print(`Draft saved to the account (uid ${result.uid}). Nothing was sent.`);
241
+ });
242
+ return;
243
+ }
244
+
245
+ // Send: preview first, always. The daemon would refuse an unconfirmed call
246
+ // anyway; showing exactly what would leave the machine is the point.
247
+ if (!confirmed) {
248
+ ctx.print([
249
+ 'This would send:',
250
+ ` To: ${parsed.to}`,
251
+ ` Subject: ${parsed.subject}`,
252
+ '',
253
+ parsed.body,
254
+ '',
255
+ 'Nothing has been sent. Re-run the same line with --confirm to send it.',
256
+ ].join('\n'));
257
+ return;
258
+ }
259
+
260
+ await withStatusOnFailure(ctx, gateway, 'send', async () => {
261
+ const result = await gateway.invoke('email.send', {
262
+ ...EXPLICIT_WRITE_INVOCATION_CONTEXT,
263
+ body: { to: parsed.to, subject: parsed.subject, body: parsed.body, confirm: true },
264
+ }) as { readonly messageId: string; readonly sentAt: string };
265
+ ctx.print(`Sent to ${parsed.to} at ${result.sentAt} (message id ${result.messageId}).`);
266
+ });
267
+ }
@@ -26,7 +26,19 @@ interface RevertHunkResult {
26
26
  readonly refusal: { readonly reason: string } | null;
27
27
  }
28
28
 
29
- const INVOKE_CONTEXT = { context: { clientKind: 'tui' as const } };
29
+ /**
30
+ * The review UI's invoke context.
31
+ *
32
+ * `explicitUserRequest: true` is honest here and only here-shaped: this path
33
+ * runs because a person is looking at a hunk and pressed a key. Scheduled
34
+ * work, triggers and channel-driven work must never set it — the distinction
35
+ * is exactly "did the owner ask for this right now", and it is what lets a
36
+ * confirmation-gated verb tell an authorized action apart from one initiated
37
+ * by content.
38
+ */
39
+ const INVOKE_CONTEXT = {
40
+ context: { clientKind: 'tui' as const, metadata: { explicitUserRequest: true } },
41
+ };
30
42
 
31
43
  /** True for the honest 409 the revert verb throws when the hunk drifted since it was captured. */
32
44
  function isConflict(err: unknown): boolean {
@@ -79,6 +79,8 @@ import { registerReviewRuntimeCommands } from './commands/review-runtime.ts';
79
79
  import { registerPaletteRuntimeCommands } from './commands/palette-runtime.ts';
80
80
  import { registerCiRuntimeCommands } from './commands/ci-runtime.ts';
81
81
  import { registerCheckinRuntimeCommands } from './commands/checkin-runtime.ts';
82
+ import { registerMailRuntimeCommands } from './commands/mail-runtime.ts';
83
+ import { registerCalendarRuntimeCommands } from './commands/calendar-runtime.ts';
82
84
  import { registerPrincipalsRuntimeCommands } from './commands/principals-runtime.ts';
83
85
 
84
86
  /**
@@ -173,6 +175,8 @@ export const BUILTIN_COMMAND_GROUPS: readonly BuiltinCommandGroup[] = [
173
175
  { category: 'Diff & Review', register: registerReviewRuntimeCommands },
174
176
  { category: 'CI', register: registerCiRuntimeCommands },
175
177
  { category: 'Check-in', register: registerCheckinRuntimeCommands },
178
+ { category: 'Mail & Calendar', register: registerMailRuntimeCommands },
179
+ { category: 'Mail & Calendar', register: registerCalendarRuntimeCommands },
176
180
  { category: 'Principals', register: registerPrincipalsRuntimeCommands },
177
181
  { category: 'Policy', register: (registry) => registry.register(policyCommand) },
178
182
  { category: 'Providers', register: (registry) => registry.register(providerCommand) },
@@ -0,0 +1,102 @@
1
+ /**
2
+ * settings-modal-connections.ts — the Connections category of the settings
3
+ * workspace: whether mail and calendar are actually usable, and if not, the
4
+ * exact next step.
5
+ *
6
+ * This category exists because a capability reachable only by typing a slash
7
+ * command is not, as far as someone clicking through the workspace is
8
+ * concerned, wired up at all. `/mail status` and `/calendar status` answer the
9
+ * same question; a person opening `/settings` to see what is connected should
10
+ * not have to already know those commands exist.
11
+ *
12
+ * ## Why the rows arrive asynchronously
13
+ *
14
+ * Every other settings category reads a value out of config and renders it.
15
+ * This one cannot: "is mail connected?" is only answerable by asking the
16
+ * daemon's handler, which is real I/O. The workspace renders synchronously, so
17
+ * the rows start in `checking` — which is true — and the refresh replaces them
18
+ * and asks for a re-render, the same contract the Services surface uses for its
19
+ * inspections. Nothing is ever shown as "not configured" merely because an
20
+ * answer has not arrived yet; that would be a guess rendered as a fact.
21
+ *
22
+ * No credential is read, held, or rendered here. The probe asks each surface
23
+ * for one item and keeps only whether it failed and why.
24
+ */
25
+
26
+ import type { GatewayMethodCatalog } from '@pellux/goodvibes-sdk/platform/control-plane';
27
+ import {
28
+ connectionSurfaceLabel,
29
+ probeConnection,
30
+ type ConnectionStatus,
31
+ type ConnectionSurface,
32
+ } from './commands/connection-status.ts';
33
+
34
+ /** The surfaces this category covers, in display order. */
35
+ export const CONNECTION_SURFACES: readonly ConnectionSurface[] = ['mail', 'calendar'];
36
+
37
+ /**
38
+ * The pre-probe rows.
39
+ *
40
+ * Stated as `checking` rather than blank so the category is never empty and
41
+ * never asserts a state it has not established.
42
+ */
43
+ export function initialConnectionEntries(): ConnectionStatus[] {
44
+ return CONNECTION_SURFACES.map((surface) => ({
45
+ surface,
46
+ state: 'checking' as const,
47
+ detail: `Asking the daemon whether ${connectionSurfaceLabel(surface).toLowerCase()} is reachable…`,
48
+ nextActions: [],
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * Probe every surface. Returns rows in `CONNECTION_SURFACES` order so the
54
+ * category never reorders itself under the cursor between refreshes.
55
+ */
56
+ export async function buildConnectionEntries(
57
+ gateway: GatewayMethodCatalog | undefined,
58
+ ): Promise<ConnectionStatus[]> {
59
+ return Promise.all(CONNECTION_SURFACES.map((surface) => probeConnection(gateway, surface)));
60
+ }
61
+
62
+ /**
63
+ * The mutable slice of the settings workspace this module drives. Declared
64
+ * structurally rather than importing the class, so the split stays a file-size
65
+ * split and does not become an import cycle.
66
+ */
67
+ export interface ConnectionsHost {
68
+ active: boolean;
69
+ selectedIndex: number;
70
+ connectionEntries: ConnectionStatus[];
71
+ connectionsRefreshing: boolean;
72
+ gatewayMethods: GatewayMethodCatalog | null;
73
+ requestRender: (() => void) | null;
74
+ }
75
+
76
+ /**
77
+ * Refresh the rows from the daemon and repaint.
78
+ *
79
+ * A second call while one is in flight is dropped: entering the tab repeatedly
80
+ * should not queue a probe per keystroke. A probe that lands after the
81
+ * workspace closed is discarded rather than written, so reopening starts from
82
+ * `checking` instead of resurrecting an answer about a finished session.
83
+ */
84
+ export async function refreshConnectionEntries(host: ConnectionsHost): Promise<void> {
85
+ if (host.connectionsRefreshing) return;
86
+ host.connectionsRefreshing = true;
87
+ try {
88
+ const entries = await buildConnectionEntries(host.gatewayMethods ?? undefined);
89
+ if (!host.active) return;
90
+ host.connectionEntries = entries;
91
+ host.requestRender?.();
92
+ } finally {
93
+ host.connectionsRefreshing = false;
94
+ }
95
+ }
96
+
97
+ /** The selected row, clamped, or null when the category is empty. */
98
+ export function selectedConnectionEntry(host: ConnectionsHost): ConnectionStatus | null {
99
+ const entries = host.connectionEntries;
100
+ if (entries.length === 0) return null;
101
+ return entries[Math.max(0, Math.min(entries.length - 1, host.selectedIndex))] ?? null;
102
+ }
@@ -45,6 +45,7 @@ export type SettingsCategory =
45
45
  | 'agents'
46
46
  | 'security'
47
47
  | 'integrations'
48
+ | 'connections'
48
49
  | 'policy'
49
50
  | 'notifications'
50
51
  | 'release'
@@ -62,7 +63,7 @@ export const SETTINGS_CATEGORY_GROUPS: ReadonlyArray<{
62
63
  { label: 'Interface', categories: ['display', 'ui', 'behavior', 'notifications', 'permissions', 'policy', 'security'] },
63
64
  { label: 'AI Routing', categories: ['provider', 'pricing', 'subscriptions', 'helper', 'tools', 'tts', 'voice'] },
64
65
  { label: 'Service & Network', categories: ['service', 'daemon', 'network', 'controlPlane', 'httpListener', 'web', 'relay'] },
65
- { label: 'Surfaces & Cloud', categories: ['surfaces', 'device', 'conversationGate', 'integrations', 'mcp', 'cloudflare'] },
66
+ { label: 'Surfaces & Cloud', categories: ['surfaces', 'device', 'conversationGate', 'integrations', 'connections', 'mcp', 'cloudflare'] },
66
67
  { label: 'Automation', categories: ['batch', 'automation', 'checkin', 'watchers', 'orchestration', 'planner', 'wrfc'] },
67
68
  { label: 'Runtime & Data', categories: ['storage', 'atRest', 'sandbox', 'fetch', 'agents', 'runtime', 'power', 'memory', 'cache', 'telemetry', 'diagnostics', 'learning'] },
68
69
  { label: 'Advanced', categories: ['release', 'update', 'danger'] },