@pellux/goodvibes-tui 1.24.2 → 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.
- package/CHANGELOG.md +17 -0
- package/README.md +1 -1
- package/bin/goodvibes-daemon +24 -0
- package/docs/foundation-artifacts/operator-contract.json +3237 -1096
- package/package.json +7 -7
- package/src/config/goodvibes-home.ts +139 -0
- package/src/daemon/cli.ts +17 -6
- package/src/daemon/handlers/index.ts +7 -6
- package/src/input/commands/calendar-runtime.ts +245 -0
- package/src/input/commands/connection-status.ts +258 -0
- package/src/input/commands/mail-runtime.ts +267 -0
- package/src/input/commands/review-runtime.ts +13 -1
- package/src/input/commands.ts +4 -0
- package/src/input/settings-modal-connections.ts +102 -0
- package/src/input/settings-modal-types.ts +2 -1
- package/src/input/settings-modal.ts +27 -0
- package/src/main.ts +7 -2
- package/src/renderer/settings-modal-connections.ts +63 -0
- package/src/renderer/settings-modal-helpers.ts +2 -0
- package/src/renderer/settings-modal.ts +5 -0
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/context.ts +9 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +0 -4
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/services.ts +21 -24
- package/src/shell/ui-openers.ts +7 -0
- package/src/verification/verification-ledger.ts +58 -0
- package/src/version.ts +1 -1
- package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
- package/src/daemon/handlers/calendar/ics.ts +0 -556
- package/src/daemon/handlers/calendar/index.ts +0 -316
- package/src/daemon/handlers/email/config.ts +0 -164
- package/src/daemon/handlers/email/imap-connector.ts +0 -441
- package/src/daemon/handlers/email/imap-parsing.ts +0 -499
- package/src/daemon/handlers/email/index.ts +0 -43
- package/src/daemon/handlers/email/read-handlers.ts +0 -80
- package/src/daemon/handlers/email/runtime.ts +0 -140
- package/src/daemon/handlers/email/smtp-connector.ts +0 -557
- package/src/daemon/handlers/email/validation.ts +0 -147
- 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
|
-
|
|
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 {
|
package/src/input/commands.ts
CHANGED
|
@@ -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'] },
|