@pellux/goodvibes-agent 1.5.5 → 1.5.6

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 (165) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +1 -1
  3. package/dist/package/main.js +94130 -79335
  4. package/docs/tools-and-commands.md +2 -1
  5. package/package.json +6 -3
  6. package/src/agent/autonomy-schedule-format.ts +1 -1
  7. package/src/agent/autonomy-schedule.ts +3 -3
  8. package/src/agent/calendar/calendar-oauth-service.ts +324 -0
  9. package/src/agent/calendar-subscription-registry.ts +497 -0
  10. package/src/agent/email/email-service.ts +57 -0
  11. package/src/agent/email/smtp-client.ts +30 -10
  12. package/src/agent/memory-prompt.ts +143 -12
  13. package/src/agent/operator-actions.ts +26 -22
  14. package/src/agent/prompt-context-receipts.ts +45 -18
  15. package/src/agent/reminder-schedule-format.ts +1 -1
  16. package/src/agent/reminder-schedule.ts +3 -3
  17. package/src/agent/routine-schedule-format.ts +2 -2
  18. package/src/agent/routine-schedule-promotion.ts +5 -5
  19. package/src/agent/routine-schedule-receipts.ts +2 -2
  20. package/src/agent/schedule-edit-format.ts +1 -1
  21. package/src/agent/schedule-edit.ts +5 -5
  22. package/src/agent/session-registration.ts +281 -0
  23. package/src/agent/vibe-file.ts +50 -0
  24. package/src/cli/local-library-command-shared.ts +146 -0
  25. package/src/cli/management.ts +2 -1
  26. package/src/cli/personas-command.ts +252 -0
  27. package/src/cli/resume-relaunch-notice.ts +151 -0
  28. package/src/cli/routines-command.ts +25 -2
  29. package/src/cli/service-posture.ts +3 -2
  30. package/src/cli/skill-bundle-command.ts +175 -0
  31. package/src/cli/skills-command.ts +309 -0
  32. package/src/cli/status.ts +43 -7
  33. package/src/cli/tui-startup.ts +24 -1
  34. package/src/config/agent-settings-policy.ts +0 -1
  35. package/src/config/secret-config.ts +4 -0
  36. package/src/core/conversation-rendering.ts +20 -11
  37. package/src/core/focus-tracker.ts +41 -0
  38. package/src/core/system-message-noise.ts +108 -0
  39. package/src/core/system-message-router.ts +54 -1
  40. package/src/core/thinking-overlay.ts +83 -0
  41. package/src/input/agent-workspace-access-command-editor-submission.ts +75 -108
  42. package/src/input/agent-workspace-access-command-editors.ts +108 -128
  43. package/src/input/agent-workspace-activation.ts +15 -0
  44. package/src/input/agent-workspace-basic-command-editor-submission.ts +292 -513
  45. package/src/input/agent-workspace-basic-command-editors.ts +433 -564
  46. package/src/input/agent-workspace-calendar-connect-editor.ts +116 -0
  47. package/src/input/agent-workspace-calendar-oauth-editor.ts +152 -0
  48. package/src/input/agent-workspace-calendar-subscribe-editor.ts +128 -0
  49. package/src/input/agent-workspace-categories.ts +7 -2
  50. package/src/input/agent-workspace-channel-command-editor-submission.ts +38 -52
  51. package/src/input/agent-workspace-channel-command-editors.ts +42 -46
  52. package/src/input/agent-workspace-command-editor-engine.ts +133 -0
  53. package/src/input/agent-workspace-command-editor.ts +4 -0
  54. package/src/input/agent-workspace-direct-editor-submission.ts +59 -0
  55. package/src/input/agent-workspace-email-connect-editor.ts +162 -0
  56. package/src/input/agent-workspace-knowledge-command-editor-submission.ts +52 -81
  57. package/src/input/agent-workspace-knowledge-command-editors.ts +71 -71
  58. package/src/input/agent-workspace-library-command-editor-submission.ts +10 -28
  59. package/src/input/agent-workspace-library-command-editors.ts +16 -2
  60. package/src/input/agent-workspace-live-counters.ts +55 -0
  61. package/src/input/agent-workspace-mcp-command-editor-submission.ts +60 -82
  62. package/src/input/agent-workspace-mcp-command-editors.ts +52 -0
  63. package/src/input/agent-workspace-media-command-editor-submission.ts +12 -39
  64. package/src/input/agent-workspace-media-command-editors.ts +10 -8
  65. package/src/input/agent-workspace-memory-command-editor-submission.ts +76 -151
  66. package/src/input/agent-workspace-memory-command-editors.ts +116 -141
  67. package/src/input/agent-workspace-operations-command-editor-submission.ts +131 -184
  68. package/src/input/agent-workspace-operations-command-editors.ts +150 -162
  69. package/src/input/agent-workspace-provider-command-editor-submission.ts +60 -106
  70. package/src/input/agent-workspace-provider-command-editors.ts +58 -68
  71. package/src/input/agent-workspace-search.ts +8 -1
  72. package/src/input/agent-workspace-session-command-editor-submission.ts +104 -132
  73. package/src/input/agent-workspace-session-command-editors.ts +160 -195
  74. package/src/input/agent-workspace-settings.ts +40 -1
  75. package/src/input/agent-workspace-skill-bundle-command-editor-submission.ts +55 -71
  76. package/src/input/agent-workspace-skill-bundle-command-editors.ts +65 -69
  77. package/src/input/agent-workspace-snapshot-builders.ts +431 -0
  78. package/src/input/agent-workspace-snapshot-config.ts +43 -0
  79. package/src/input/agent-workspace-snapshot.ts +198 -432
  80. package/src/input/agent-workspace-task-command-editor-submission.ts +34 -44
  81. package/src/input/agent-workspace-task-command-editors.ts +27 -28
  82. package/src/input/agent-workspace-types.ts +35 -1
  83. package/src/input/agent-workspace.ts +5 -14
  84. package/src/input/command-registry.ts +23 -1
  85. package/src/input/commands/calendar-connect-runtime.ts +226 -0
  86. package/src/input/commands/calendar-runtime.ts +110 -7
  87. package/src/input/commands/calendar-subscription-runtime.ts +225 -0
  88. package/src/input/commands/email-runtime.ts +100 -40
  89. package/src/input/commands/knowledge.ts +1 -1
  90. package/src/input/commands/network-scan-runtime.ts +75 -0
  91. package/src/input/commands/operator-actions-runtime.ts +9 -6
  92. package/src/input/commands/personas-runtime.ts +1 -1
  93. package/src/input/commands/schedule-runtime.ts +4 -4
  94. package/src/input/commands/session-content.ts +13 -1
  95. package/src/input/commands/session-workflow.ts +15 -20
  96. package/src/input/commands/session.ts +3 -1
  97. package/src/input/commands/shell-core.ts +20 -6
  98. package/src/input/commands.ts +2 -2
  99. package/src/input/delete-key-policy.ts +46 -0
  100. package/src/input/feed-context-factory.ts +10 -0
  101. package/src/input/handler-feed.ts +87 -0
  102. package/src/input/handler-modal-routes.ts +6 -1
  103. package/src/input/handler.ts +5 -0
  104. package/src/input/panel-paste-flood-guard.ts +94 -0
  105. package/src/input/settings-modal-types.ts +5 -3
  106. package/src/input/settings-modal.ts +21 -0
  107. package/src/main.ts +36 -36
  108. package/src/permissions/approval-posture.ts +141 -0
  109. package/src/renderer/agent-workspace-context-lines.ts +10 -2
  110. package/src/renderer/compositor.ts +27 -4
  111. package/src/renderer/diff.ts +61 -18
  112. package/src/renderer/fullscreen-primitives.ts +37 -18
  113. package/src/renderer/markdown.ts +20 -10
  114. package/src/renderer/modal-factory.ts +25 -15
  115. package/src/renderer/overlay-box.ts +23 -12
  116. package/src/renderer/process-indicator.ts +8 -3
  117. package/src/renderer/prompt-content-width.ts +16 -0
  118. package/src/renderer/settings-modal-helpers.ts +2 -0
  119. package/src/renderer/settings-modal.ts +2 -0
  120. package/src/renderer/startup-theme-probe.ts +35 -0
  121. package/src/renderer/status-glyphs.ts +11 -15
  122. package/src/renderer/system-message.ts +17 -2
  123. package/src/renderer/term-caps.ts +318 -0
  124. package/src/renderer/terminal-bg-probe.ts +373 -0
  125. package/src/renderer/terminal-escapes.ts +24 -0
  126. package/src/renderer/theme-mode-config.ts +87 -0
  127. package/src/renderer/theme.ts +241 -0
  128. package/src/renderer/thinking.ts +12 -3
  129. package/src/renderer/tool-call.ts +7 -3
  130. package/src/renderer/ui-factory.ts +92 -36
  131. package/src/renderer/ui-primitives.ts +33 -93
  132. package/src/runtime/bootstrap-core.ts +15 -0
  133. package/src/runtime/bootstrap-hook-bridge.ts +6 -0
  134. package/src/runtime/bootstrap-shell.ts +2 -0
  135. package/src/runtime/bootstrap.ts +68 -5
  136. package/src/runtime/calendar-boot-refresh.ts +105 -0
  137. package/src/runtime/lan-scan-consent.ts +253 -0
  138. package/src/runtime/services.ts +127 -2
  139. package/src/runtime/session-spine-rest-transport.ts +160 -0
  140. package/src/runtime/terminal-output-guard.ts +6 -1
  141. package/src/runtime/ui-services.ts +3 -0
  142. package/src/shell/agent-workspace-fullscreen.ts +5 -0
  143. package/src/shell/terminal-focus-mode.ts +120 -0
  144. package/src/shell/ui-openers.ts +13 -4
  145. package/src/tools/agent-harness-autonomy-intake.ts +5 -5
  146. package/src/tools/agent-harness-autonomy-queue.ts +1 -1
  147. package/src/tools/agent-harness-background-processes.ts +2 -1
  148. package/src/tools/agent-harness-metadata.ts +16 -8
  149. package/src/tools/agent-harness-operator-methods.ts +44 -8
  150. package/src/tools/agent-harness-personal-ops-discovery.ts +57 -10
  151. package/src/tools/agent-harness-personal-ops-lanes.ts +17 -5
  152. package/src/tools/agent-harness-personal-ops-operations.ts +7 -7
  153. package/src/tools/agent-harness-personal-ops-types.ts +6 -0
  154. package/src/tools/agent-harness-prompt-context.ts +26 -12
  155. package/src/tools/agent-harness-workspace-actions.ts +4 -0
  156. package/src/tools/agent-local-registry-args.ts +117 -0
  157. package/src/tools/agent-local-registry-memory.ts +227 -0
  158. package/src/tools/agent-local-registry-tool.ts +19 -237
  159. package/src/tools/agent-operator-briefing-tool.ts +2 -2
  160. package/src/tools/agent-operator-method-tool.ts +13 -0
  161. package/src/tools/agent-policy-explanation.ts +39 -4
  162. package/src/tools/agent-schedule-tool.ts +5 -5
  163. package/src/utils/terminal-width.ts +98 -1
  164. package/src/version.ts +1 -1
  165. package/src/cli/local-library-command.ts +0 -825
@@ -0,0 +1,497 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import type { ShellPathService } from '@/runtime/index.ts';
4
+ import {
5
+ SubscriptionStore,
6
+ expandEvent,
7
+ maskFeedUrl,
8
+ DEFAULT_REFRESH_INTERVAL_MS,
9
+ type CalendarEvent,
10
+ type FeedFetcher,
11
+ type SubscriptionHealth,
12
+ } from '@pellux/goodvibes-sdk/platform/calendar';
13
+ import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
14
+
15
+ /**
16
+ * Agent-side registry for READ-ONLY external calendar SUBSCRIPTIONS (iCalendar
17
+ * feeds — Google "secret address", Outlook published .ics, or any .ics URL).
18
+ *
19
+ * The parse/RRULE/fetch-status engine is the SDK's `platform/calendar`
20
+ * (SubscriptionStore + expandEvent). This class owns the two things the SDK
21
+ * deliberately leaves to the consumer:
22
+ * - PERSISTENCE. Feed events are cached to a JSON store next to the local
23
+ * calendar so `/calendar list` merges them with no network call; subscription
24
+ * metadata (name, cadence, validators, last-status) lives there too.
25
+ * - SECRET STORAGE. The feed URL is secrets-adjacent (a Google secret address
26
+ * grants read access to the calendar), so it is stored ONLY through the
27
+ * SecretsManager under a per-subscription key; the JSON store holds the secret
28
+ * KEY, never the URL. `maskFeedUrl` is used anywhere a URL would be shown.
29
+ *
30
+ * Consent = subscribing. Nothing here fetches a URL the user did not explicitly
31
+ * add. The subscribe verb states what will be fetched and how often before saving.
32
+ *
33
+ * Subscribed events are READ-ONLY: they carry a `sub:<name>:` id namespace so the
34
+ * local-store edit/delete paths can refuse them with a plain reason.
35
+ */
36
+
37
+ // ──────────────────────────────────────────────────────────────────
38
+ // Types
39
+ // ──────────────────────────────────────────────────────────────────
40
+
41
+ /** The SDK CalendarEvent as cached on disk (structurally identical; JSON-safe). */
42
+ export type CachedFeedEvent = CalendarEvent;
43
+
44
+ interface SubscriptionMeta {
45
+ readonly name: string;
46
+ /** SecretsManager key holding the feed URL. */
47
+ readonly secretKey: string;
48
+ readonly refreshIntervalMs: number;
49
+ readonly lastFetchedAt?: number;
50
+ readonly lastSucceededAt?: number;
51
+ readonly etag?: string;
52
+ readonly lastModified?: string;
53
+ readonly lastResult?: 'ok' | 'not-modified' | 'unreachable' | 'parse-error';
54
+ readonly lastDetail?: string;
55
+ readonly eventCount?: number;
56
+ readonly events: readonly CachedFeedEvent[];
57
+ }
58
+
59
+ interface SubscriptionStoreFile {
60
+ readonly version: 1;
61
+ readonly subscriptions: readonly SubscriptionMeta[];
62
+ }
63
+
64
+ /** A subscription's status as shown by `/calendar subscriptions`. */
65
+ export interface SubscriptionStatus {
66
+ readonly name: string;
67
+ readonly maskedUrl: string;
68
+ readonly health: SubscriptionHealth;
69
+ readonly detail?: string;
70
+ readonly refreshIntervalMs: number;
71
+ readonly lastSucceededAt?: number;
72
+ readonly eventCount: number;
73
+ }
74
+
75
+ /** One subscribed occurrence, flattened for a merged, source-labeled view. */
76
+ export interface SubscribedOccurrence {
77
+ /** Read-only namespaced id: `sub:<subscriptionName>:<uid>`. */
78
+ readonly id: string;
79
+ readonly subscription: string;
80
+ readonly title: string;
81
+ /** 'YYYY-MM-DD' or 'YYYY-MM-DDTHH:mm:ss[Z]'. */
82
+ readonly start: string;
83
+ readonly allDay: boolean;
84
+ readonly location?: string;
85
+ readonly tzid?: string;
86
+ /** True when the source RRULE was not fully expanded — shown as a marker. */
87
+ readonly recurrenceNotFullyExpanded: boolean;
88
+ }
89
+
90
+ export type SubscribeResult =
91
+ | { readonly ok: true; readonly name: string; readonly eventCount: number; readonly calendarName?: string; readonly refreshIntervalMs: number }
92
+ | { readonly ok: false; readonly stage: 'fetch' | 'parse' | 'duplicate'; readonly detail: string };
93
+
94
+ export type ValidateResult =
95
+ | { readonly ok: true; readonly derivedName: string; readonly eventCount: number; readonly calendarName?: string }
96
+ | { readonly ok: false; readonly stage: 'fetch' | 'parse'; readonly detail: string };
97
+
98
+ export interface RefreshOutcome {
99
+ readonly name: string;
100
+ readonly outcome: 'updated' | 'not-modified' | 'skipped' | 'unreachable' | 'parse-error';
101
+ readonly detail?: string;
102
+ readonly eventCount?: number;
103
+ }
104
+
105
+ /** The SecretsManager surface this registry needs. */
106
+ export interface SubscriptionSecretStore {
107
+ readonly get: (key: string) => Promise<string | null>;
108
+ readonly set: (key: string, value: string) => Promise<void>;
109
+ readonly delete?: (key: string) => Promise<void>;
110
+ }
111
+
112
+ // ──────────────────────────────────────────────────────────────────
113
+ // Helpers
114
+ // ──────────────────────────────────────────────────────────────────
115
+
116
+ const STORE_VERSION = 1;
117
+ /** Data older than this multiple of the refresh interval reads as stale. */
118
+ const STALE_MULTIPLE = 2;
119
+
120
+ function isRecord(value: unknown): value is Record<string, unknown> {
121
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
122
+ }
123
+
124
+ function secretKeyForName(name: string): string {
125
+ const slug = name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_+|_+$/g, '').toUpperCase();
126
+ return `GOODVIBES_CALENDAR_SUB_${slug || 'FEED'}`;
127
+ }
128
+
129
+ type AgentLocalStorePaths = Pick<ShellPathService, 'resolveUserPath'>;
130
+
131
+ export function subscriptionStorePath(shellPaths: AgentLocalStorePaths): string {
132
+ return shellPaths.resolveUserPath(GOODVIBES_AGENT_SURFACE_ROOT, 'calendar', 'subscriptions.json');
133
+ }
134
+
135
+ /** The real HTTP fetcher — conditional GET with etag/last-modified, mapped to FeedFetchResult. */
136
+ export function createHttpFeedFetcher(): FeedFetcher {
137
+ return async (req) => {
138
+ try {
139
+ const headers: Record<string, string> = { Accept: 'text/calendar, text/plain, */*' };
140
+ if (req.etag) headers['If-None-Match'] = req.etag;
141
+ if (req.lastModified) headers['If-Modified-Since'] = req.lastModified;
142
+ const res = await fetch(req.url, { headers, redirect: 'follow' });
143
+ if (res.status === 304) {
144
+ return { kind: 'not-modified', ...(res.headers.get('etag') ? { etag: res.headers.get('etag')! } : {}), ...(res.headers.get('last-modified') ? { lastModified: res.headers.get('last-modified')! } : {}) };
145
+ }
146
+ if (!res.ok) {
147
+ return { kind: 'error', status: res.status, message: res.statusText || `HTTP ${res.status}` };
148
+ }
149
+ const body = await res.text();
150
+ return { kind: 'ok', body, ...(res.headers.get('etag') ? { etag: res.headers.get('etag')! } : {}), ...(res.headers.get('last-modified') ? { lastModified: res.headers.get('last-modified')! } : {}) };
151
+ } catch (error) {
152
+ return { kind: 'error', message: error instanceof Error ? error.message : String(error) };
153
+ }
154
+ };
155
+ }
156
+
157
+ // ──────────────────────────────────────────────────────────────────
158
+ // Registry
159
+ // ──────────────────────────────────────────────────────────────────
160
+
161
+ export interface CalendarSubscriptionRegistryOptions {
162
+ readonly storePath: string;
163
+ readonly secrets: SubscriptionSecretStore;
164
+ readonly fetcher: FeedFetcher;
165
+ readonly clock?: () => number;
166
+ readonly defaultRefreshIntervalMs?: number;
167
+ }
168
+
169
+ export class CalendarSubscriptionRegistry {
170
+ private readonly storePath: string;
171
+ private readonly secrets: SubscriptionSecretStore;
172
+ private readonly fetcher: FeedFetcher;
173
+ private readonly clock: () => number;
174
+ private readonly defaultInterval: number;
175
+ /**
176
+ * F2 fix: every store WRITE (the read-merge-write critical section of
177
+ * subscribe/unsubscribe/refresh) is serialized through this single
178
+ * in-process promise chain, one instance per registry. Network calls
179
+ * (feed fetch, secret-store reads) are NOT held behind this queue — only
180
+ * the final read-fresh/merge/write section is — so a slow refresh never
181
+ * blocks a concurrent subscribe/unsubscribe from making progress; it only
182
+ * ensures the two operations' WRITES never race each other and neither
183
+ * ever blind-overwrites a change the other just made.
184
+ */
185
+ private mutationQueue: Promise<unknown> = Promise.resolve();
186
+
187
+ public constructor(options: CalendarSubscriptionRegistryOptions) {
188
+ this.storePath = options.storePath;
189
+ this.secrets = options.secrets;
190
+ this.fetcher = options.fetcher;
191
+ this.clock = options.clock ?? (() => Date.now());
192
+ this.defaultInterval = options.defaultRefreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
193
+ }
194
+
195
+ /**
196
+ * Runs `fn` after every previously queued mutation has settled, chaining
197
+ * this call onto the queue regardless of whether earlier calls resolved or
198
+ * rejected (a failed mutation must never wedge the queue for the ones
199
+ * behind it). Returns `fn`'s own result/rejection to its caller.
200
+ */
201
+ private withMutationLock<T>(fn: () => Promise<T> | T): Promise<T> {
202
+ const run = this.mutationQueue.then(fn, fn);
203
+ this.mutationQueue = run.then(
204
+ () => undefined,
205
+ () => undefined,
206
+ );
207
+ return run;
208
+ }
209
+
210
+ public static create(shellPaths: AgentLocalStorePaths, secrets: SubscriptionSecretStore, fetcher?: FeedFetcher): CalendarSubscriptionRegistry {
211
+ return new CalendarSubscriptionRegistry({ storePath: subscriptionStorePath(shellPaths), secrets, fetcher: fetcher ?? createHttpFeedFetcher() });
212
+ }
213
+
214
+ /** Fetch a feed WITHOUT saving it — the subscribe preview / wizard validate step. */
215
+ public async validate(url: string, requestedName?: string): Promise<ValidateResult> {
216
+ const store = new SubscriptionStore({ fetcher: this.fetcher, clock: this.clock });
217
+ const res = await store.validateByFetch(url, requestedName);
218
+ if (!res.ok) return { ok: false, stage: res.stage, detail: res.detail };
219
+ return { ok: true, derivedName: res.derivedName, eventCount: res.eventCount, ...(res.calendarName !== undefined ? { calendarName: res.calendarName } : {}) };
220
+ }
221
+
222
+ /**
223
+ * Subscribe: fetch once, derive the name, store the URL as a secret, and cache
224
+ * events. The network fetch/validate (`store.add`) runs unlocked — it is the
225
+ * slow part and does not need exclusive access to the store — but the
226
+ * duplicate-name check, the secret write, and the file write all happen
227
+ * inside one locked read-merge-write section (F2) so a concurrent refresh
228
+ * finishing its own write in between can never cause this subscribe to be
229
+ * silently reverted: the section always re-reads the store fresh right
230
+ * before writing.
231
+ */
232
+ public async subscribe(url: string, requestedName?: string, refreshIntervalMs?: number): Promise<SubscribeResult> {
233
+ const store = new SubscriptionStore({ fetcher: this.fetcher, clock: this.clock, ...(refreshIntervalMs !== undefined ? { defaultRefreshIntervalMs: refreshIntervalMs } : { defaultRefreshIntervalMs: this.defaultInterval }) });
234
+ const result = await store.add({ url, ...(requestedName !== undefined ? { name: requestedName } : {}), ...(refreshIntervalMs !== undefined ? { refreshIntervalMs } : {}) });
235
+ if (!result.ok) return { ok: false, stage: result.stage, detail: result.detail };
236
+ const sub = result.subscription;
237
+ const events = store.events(sub.name);
238
+
239
+ return this.withMutationLock(async () => {
240
+ const file = this.readStore();
241
+ if (file.subscriptions.some((s) => s.name === sub.name)) {
242
+ return { ok: false, stage: 'duplicate', detail: `A subscription named '${sub.name}' already exists — unsubscribe first or pass a different --name.` };
243
+ }
244
+ const secretKey = secretKeyForName(sub.name);
245
+ await this.secrets.set(secretKey, url);
246
+ const meta: SubscriptionMeta = {
247
+ name: sub.name,
248
+ secretKey,
249
+ refreshIntervalMs: sub.refreshIntervalMs,
250
+ ...(sub.lastFetchedAt !== undefined ? { lastFetchedAt: sub.lastFetchedAt } : {}),
251
+ ...(sub.lastSucceededAt !== undefined ? { lastSucceededAt: sub.lastSucceededAt } : {}),
252
+ ...(sub.etag !== undefined ? { etag: sub.etag } : {}),
253
+ ...(sub.lastModified !== undefined ? { lastModified: sub.lastModified } : {}),
254
+ lastResult: 'ok',
255
+ ...(sub.detail !== undefined ? { lastDetail: sub.detail } : {}),
256
+ eventCount: events.length,
257
+ events: [...events],
258
+ };
259
+ this.writeStore({ version: STORE_VERSION, subscriptions: [...file.subscriptions, meta] });
260
+ return { ok: true as const, name: sub.name, eventCount: events.length, refreshIntervalMs: sub.refreshIntervalMs };
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Remove a subscription, its cached events, and its stored URL secret. The
266
+ * whole read-find-delete-write body is one locked section (F2): small and
267
+ * fast, so there is no benefit to splitting it, and it guarantees the write
268
+ * is always based on a store read taken after every earlier queued mutation
269
+ * (including a refresh's write) has already landed.
270
+ */
271
+ public async unsubscribe(name: string): Promise<boolean> {
272
+ return this.withMutationLock(async () => {
273
+ const file = this.readStore();
274
+ const target = file.subscriptions.find((s) => s.name.toLowerCase() === name.trim().toLowerCase());
275
+ if (!target) return false;
276
+ if (this.secrets.delete) {
277
+ try { await this.secrets.delete(target.secretKey); } catch { /* best-effort secret cleanup */ }
278
+ }
279
+ this.writeStore({ version: STORE_VERSION, subscriptions: file.subscriptions.filter((s) => s !== target) });
280
+ return true;
281
+ });
282
+ }
283
+
284
+ /**
285
+ * Refresh one named subscription (or all when name omitted). Network, on
286
+ * demand / on boot.
287
+ *
288
+ * F2 fix: the per-target network fetches run unlocked (so a concurrent
289
+ * subscribe/unsubscribe is never blocked behind them), but the write is a
290
+ * single locked section that re-reads the store fresh and merges each
291
+ * refreshed record onto that fresh read — never onto the stale snapshot
292
+ * this method started with. Concretely: `merged = freshRead.map(record =>
293
+ * refreshedByName.get(record.name) ?? record)`. Because the merge maps
294
+ * over the FRESH list rather than over this call's original target list, a
295
+ * record removed by a concurrent unsubscribe is simply absent from the
296
+ * fresh list and is never resurrected, and a record added by a concurrent
297
+ * subscribe is already present in the fresh list and passes through
298
+ * untouched. Two concurrent refresh() calls still only ever commit through
299
+ * this same locked section one at a time, so neither can double-write or
300
+ * clobber the other's result.
301
+ */
302
+ public async refresh(name?: string, opts: { force?: boolean } = {}): Promise<RefreshOutcome[]> {
303
+ const file = this.readStore();
304
+ const targets = name
305
+ ? file.subscriptions.filter((s) => s.name.toLowerCase() === name.trim().toLowerCase())
306
+ : file.subscriptions;
307
+ if (targets.length === 0) return [];
308
+
309
+ const refreshedByName = new Map<string, SubscriptionMeta>();
310
+ const outcomes: RefreshOutcome[] = [];
311
+ for (const target of targets) {
312
+ const url = await this.secrets.get(target.secretKey);
313
+ if (url === null) {
314
+ outcomes.push({ name: target.name, outcome: 'unreachable', detail: 'Stored feed URL is missing from the secret manager — unsubscribe and re-add.' });
315
+ continue;
316
+ }
317
+ const store = new SubscriptionStore({ fetcher: this.fetcher, clock: this.clock });
318
+ store.restore([{ name: target.name, url, refreshIntervalMs: target.refreshIntervalMs, ...(target.lastFetchedAt !== undefined ? { lastFetchedAt: target.lastFetchedAt } : {}), ...(target.lastSucceededAt !== undefined ? { lastSucceededAt: target.lastSucceededAt } : {}), ...(target.etag !== undefined ? { etag: target.etag } : {}), ...(target.lastModified !== undefined ? { lastModified: target.lastModified } : {}) }]);
319
+ const report = (await store.refresh(target.name, { force: opts.force ?? true }))!;
320
+ const sub = store.get(target.name)!;
321
+ const events = report.outcome === 'updated' ? store.events(target.name) : target.events;
322
+ refreshedByName.set(target.name, {
323
+ name: target.name,
324
+ secretKey: target.secretKey,
325
+ refreshIntervalMs: sub.refreshIntervalMs,
326
+ ...(sub.lastFetchedAt !== undefined ? { lastFetchedAt: sub.lastFetchedAt } : {}),
327
+ ...(sub.lastSucceededAt !== undefined ? { lastSucceededAt: sub.lastSucceededAt } : {}),
328
+ ...(sub.etag !== undefined ? { etag: sub.etag } : {}),
329
+ ...(sub.lastModified !== undefined ? { lastModified: sub.lastModified } : {}),
330
+ lastResult: report.outcome === 'skipped' ? target.lastResult : this.resultOf(report.outcome),
331
+ ...(report.detail !== undefined ? { lastDetail: report.detail } : {}),
332
+ eventCount: events.length,
333
+ events: [...events],
334
+ });
335
+ outcomes.push({ name: target.name, outcome: report.outcome, ...(report.detail !== undefined ? { detail: report.detail } : {}), eventCount: events.length });
336
+ }
337
+ if (refreshedByName.size === 0) return outcomes;
338
+
339
+ await this.withMutationLock(() => {
340
+ const fresh = this.readStore();
341
+ const merged = fresh.subscriptions.map((s) => refreshedByName.get(s.name) ?? s);
342
+ this.writeStore({ version: STORE_VERSION, subscriptions: merged });
343
+ });
344
+ return outcomes;
345
+ }
346
+
347
+ /** Subscription statuses for `/calendar subscriptions` — no network, masked URLs. */
348
+ public async statuses(): Promise<readonly SubscriptionStatus[]> {
349
+ const file = this.readStore();
350
+ const now = this.clock();
351
+ const out: SubscriptionStatus[] = [];
352
+ for (const s of file.subscriptions) {
353
+ const url = await this.secrets.get(s.secretKey);
354
+ out.push({
355
+ name: s.name,
356
+ maskedUrl: url ? maskFeedUrl(url) : '(url missing from secret manager)',
357
+ health: this.healthOf(s, now),
358
+ ...(this.detailOf(s, now) !== undefined ? { detail: this.detailOf(s, now)! } : {}),
359
+ refreshIntervalMs: s.refreshIntervalMs,
360
+ ...(s.lastSucceededAt !== undefined ? { lastSucceededAt: s.lastSucceededAt } : {}),
361
+ eventCount: s.eventCount ?? s.events.length,
362
+ });
363
+ }
364
+ return out;
365
+ }
366
+
367
+ public hasAny(): boolean {
368
+ return this.readStore().subscriptions.length > 0;
369
+ }
370
+
371
+ public names(): readonly string[] {
372
+ return this.readStore().subscriptions.map((s) => s.name);
373
+ }
374
+
375
+ /**
376
+ * One row per cached subscribed event (the seed, unexpanded) — the merged
377
+ * `/calendar list` view. A recurring event is marked so the reader knows more
378
+ * occurrences exist; `occurrencesInWindow` is the date-windowed expansion.
379
+ */
380
+ public seeds(): readonly SubscribedOccurrence[] {
381
+ const file = this.readStore();
382
+ const out: SubscribedOccurrence[] = [];
383
+ for (const s of file.subscriptions) {
384
+ for (const event of s.events) {
385
+ const recurring = event.recurrence !== undefined;
386
+ out.push({
387
+ id: `sub:${s.name}:${event.uid}`,
388
+ subscription: s.name,
389
+ title: event.summary,
390
+ start: event.start.value,
391
+ allDay: event.start.kind === 'date',
392
+ ...(event.location !== undefined ? { location: event.location } : {}),
393
+ ...(event.start.zone === 'tzid' && event.start.tzid !== undefined ? { tzid: event.start.tzid } : {}),
394
+ recurrenceNotFullyExpanded: recurring && event.recurrence?.expansion === 'unsupported',
395
+ });
396
+ }
397
+ }
398
+ return out.sort((a, b) => a.start.localeCompare(b.start));
399
+ }
400
+
401
+ /**
402
+ * All subscribed occurrences within [fromDate, toDate] (inclusive, 'YYYY-MM-DD'),
403
+ * flattened and source-labeled for a merged read-only view. Recurring events are
404
+ * expanded through the SDK; an unsupported RRULE surfaces its seed with a marker.
405
+ */
406
+ public occurrencesInWindow(fromDate: string, toDate: string): readonly SubscribedOccurrence[] {
407
+ const file = this.readStore();
408
+ const out: SubscribedOccurrence[] = [];
409
+ for (const s of file.subscriptions) {
410
+ for (const event of s.events) {
411
+ const occ = expandEvent(event, { from: fromDate, to: toDate });
412
+ const notExpanded = event.recurrence?.expansion === 'unsupported';
413
+ for (const o of occ) {
414
+ out.push({
415
+ id: `sub:${s.name}:${event.uid}`,
416
+ subscription: s.name,
417
+ title: event.summary,
418
+ start: o.start,
419
+ allDay: event.start.kind === 'date',
420
+ ...(event.location !== undefined ? { location: event.location } : {}),
421
+ ...(event.start.zone === 'tzid' && event.start.tzid !== undefined ? { tzid: event.start.tzid } : {}),
422
+ recurrenceNotFullyExpanded: notExpanded,
423
+ });
424
+ }
425
+ }
426
+ }
427
+ return out.sort((a, b) => a.start.localeCompare(b.start));
428
+ }
429
+
430
+ // ── internals ────────────────────────────────────────────────────
431
+
432
+ private resultOf(outcome: RefreshOutcome['outcome']): SubscriptionMeta['lastResult'] {
433
+ if (outcome === 'updated') return 'ok';
434
+ if (outcome === 'not-modified') return 'not-modified';
435
+ if (outcome === 'unreachable') return 'unreachable';
436
+ if (outcome === 'parse-error') return 'parse-error';
437
+ return 'ok';
438
+ }
439
+
440
+ private healthOf(s: SubscriptionMeta, now: number): SubscriptionHealth {
441
+ if (s.lastResult === undefined) return 'never-fetched';
442
+ if (s.lastResult === 'unreachable') return 'unreachable';
443
+ if (s.lastResult === 'parse-error') return 'parse-error';
444
+ if (s.lastSucceededAt === undefined) return 'never-fetched';
445
+ if (now - s.lastSucceededAt > s.refreshIntervalMs * STALE_MULTIPLE) return 'stale';
446
+ return 'ok';
447
+ }
448
+
449
+ private detailOf(s: SubscriptionMeta, now: number): string | undefined {
450
+ if (this.healthOf(s, now) === 'stale' && s.lastSucceededAt !== undefined) {
451
+ const ageMin = Math.round((now - s.lastSucceededAt) / 60000);
452
+ return `Last updated ${ageMin} min ago (past the ${Math.round(s.refreshIntervalMs / 60000)} min refresh window).`;
453
+ }
454
+ return s.lastDetail;
455
+ }
456
+
457
+ private readStore(): SubscriptionStoreFile {
458
+ if (!existsSync(this.storePath)) return { version: STORE_VERSION, subscriptions: [] };
459
+ try {
460
+ const parsed: unknown = JSON.parse(readFileSync(this.storePath, 'utf-8'));
461
+ if (!isRecord(parsed) || !Array.isArray(parsed.subscriptions)) return { version: STORE_VERSION, subscriptions: [] };
462
+ const subscriptions = parsed.subscriptions.filter(isRecord).map(coerceMeta).filter((m): m is SubscriptionMeta => m !== null);
463
+ return { version: STORE_VERSION, subscriptions };
464
+ } catch (error) {
465
+ throw new Error(`Could not read calendar subscription store: ${error instanceof Error ? error.message : String(error)}`);
466
+ }
467
+ }
468
+
469
+ private writeStore(file: SubscriptionStoreFile): void {
470
+ mkdirSync(dirname(this.storePath), { recursive: true });
471
+ const tmp = `${this.storePath}.tmp`;
472
+ writeFileSync(tmp, `${JSON.stringify(file, null, 2)}\n`, 'utf-8');
473
+ renameSync(tmp, this.storePath);
474
+ }
475
+ }
476
+
477
+ function coerceMeta(value: Record<string, unknown>): SubscriptionMeta | null {
478
+ const name = typeof value.name === 'string' ? value.name.trim() : '';
479
+ const secretKey = typeof value.secretKey === 'string' ? value.secretKey : '';
480
+ if (!name || !secretKey) return null;
481
+ const refreshIntervalMs = typeof value.refreshIntervalMs === 'number' ? value.refreshIntervalMs : DEFAULT_REFRESH_INTERVAL_MS;
482
+ const events = Array.isArray(value.events) ? (value.events.filter(isRecord) as unknown as CachedFeedEvent[]) : [];
483
+ const lastResult = value.lastResult === 'ok' || value.lastResult === 'not-modified' || value.lastResult === 'unreachable' || value.lastResult === 'parse-error' ? value.lastResult : undefined;
484
+ return {
485
+ name,
486
+ secretKey,
487
+ refreshIntervalMs,
488
+ ...(typeof value.lastFetchedAt === 'number' ? { lastFetchedAt: value.lastFetchedAt } : {}),
489
+ ...(typeof value.lastSucceededAt === 'number' ? { lastSucceededAt: value.lastSucceededAt } : {}),
490
+ ...(typeof value.etag === 'string' ? { etag: value.etag } : {}),
491
+ ...(typeof value.lastModified === 'string' ? { lastModified: value.lastModified } : {}),
492
+ ...(lastResult !== undefined ? { lastResult } : {}),
493
+ ...(typeof value.lastDetail === 'string' ? { lastDetail: value.lastDetail } : {}),
494
+ ...(typeof value.eventCount === 'number' ? { eventCount: value.eventCount } : {}),
495
+ events,
496
+ };
497
+ }
@@ -103,6 +103,18 @@ export interface EmailSummary {
103
103
  readonly bodyPreview: string;
104
104
  }
105
105
 
106
+ /**
107
+ * Result of a connection verification pass (a connect-wizard "test connection"
108
+ * step). Never includes the raw password; `error` messages come from the
109
+ * underlying client's plain-language exceptions.
110
+ */
111
+ export interface EmailConnectionTestResult {
112
+ readonly ok: boolean;
113
+ /** Which stage failed, when ok is false. 'config' means validation failed before any connection was attempted. */
114
+ readonly stage?: 'config' | 'imap' | 'smtp';
115
+ readonly error?: string;
116
+ }
117
+
106
118
  export interface SendMailOptions {
107
119
  readonly to: string;
108
120
  readonly subject: string;
@@ -284,6 +296,51 @@ export class EmailService {
284
296
  }
285
297
  }
286
298
 
299
+ /**
300
+ * Verify the configured IMAP and SMTP connections without sending mail or
301
+ * reading the inbox — a real connectivity + authentication check for a
302
+ * connect-wizard "test connection" step. Does not require config.enabled;
303
+ * callers that want to gate readiness on enabled should check separately.
304
+ *
305
+ * Never throws — returns a result describing which stage (if any) failed,
306
+ * with a plain-language error message. Never includes the raw password.
307
+ */
308
+ async testConnection(): Promise<EmailConnectionTestResult> {
309
+ const config = readEmailConfig(this.deps.getConfig);
310
+ const errors = validateEmailConfig(config);
311
+ if (errors.length > 0) {
312
+ return { ok: false, stage: 'config', error: errors.join('; ') };
313
+ }
314
+
315
+ let password: string;
316
+ try {
317
+ password = await resolveEmailPassword(config.passwordRef, this.deps.secretsManager);
318
+ } catch (err) {
319
+ return { ok: false, stage: 'config', error: err instanceof Error ? err.message : String(err) };
320
+ }
321
+
322
+ try {
323
+ const socketFactory = this.deps.imapSocketFactory ?? createImapTlsSocket;
324
+ const socket = await socketFactory(config.imapHost, config.imapPort);
325
+ const client = new ImapClient({ socket, username: config.username, password });
326
+ await client.open();
327
+ await client.logout();
328
+ } catch (err) {
329
+ return { ok: false, stage: 'imap', error: err instanceof Error ? err.message : String(err) };
330
+ }
331
+
332
+ try {
333
+ const socketFactory = this.deps.smtpSocketFactory ?? this.defaultSmtpSocketFactory(config.smtpPort, config.smtpSecurity);
334
+ const socket = await socketFactory(config.smtpHost, config.smtpPort);
335
+ const client = new SmtpClient({ socket, hostname: config.smtpHost, username: config.username, password });
336
+ await client.verifyAuth();
337
+ } catch (err) {
338
+ return { ok: false, stage: 'smtp', error: err instanceof Error ? err.message : String(err) };
339
+ }
340
+
341
+ return { ok: true };
342
+ }
343
+
287
344
  /**
288
345
  * Send a plain-text email.
289
346
  * Requires `confirm: true` at the call site — throws without it.
@@ -245,6 +245,22 @@ export class SmtpClient {
245
245
  this.options = options;
246
246
  }
247
247
 
248
+ /**
249
+ * Connect, negotiate EHLO, and authenticate — then QUIT without sending any
250
+ * mail. Used to verify SMTP credentials/host reachability (a connect-wizard
251
+ * "test connection" step) without the side effect of an actual send.
252
+ * Throws with a plain-language message on any failure stage.
253
+ */
254
+ async verifyAuth(): Promise<void> {
255
+ const { hostname, username, password } = this.options;
256
+ const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
257
+ const session = new SmtpSession(this.options.socket, timeoutMs);
258
+ const { capabilities } = await this.greetAndEhlo(session, hostname);
259
+ await this.authenticate(session, capabilities, username, password);
260
+ await session.send('QUIT');
261
+ session.destroy();
262
+ }
263
+
248
264
  /**
249
265
  * Send a plain-text email.
250
266
  * Callers must ensure the caller-side confirms the send before calling this.
@@ -253,16 +269,7 @@ export class SmtpClient {
253
269
  const { hostname, username, password } = this.options;
254
270
  const timeoutMs = this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
255
271
  const session = new SmtpSession(this.options.socket, timeoutMs);
256
-
257
- // Read server greeting
258
- const greeting = await session.readResponse();
259
- if (greeting.code !== 220) {
260
- throw new Error(`SMTP unexpected greeting: ${greeting.lines.join(' | ')}`);
261
- }
262
-
263
- // EHLO
264
- const ehlo = await session.cmd(`EHLO ${hostname}`, 250);
265
- const capabilities = ehlo.lines.map((l) => l.slice(4).trim().toUpperCase());
272
+ const { capabilities } = await this.greetAndEhlo(session, hostname);
266
273
 
267
274
  // AUTH
268
275
  await this.authenticate(session, capabilities, username, password);
@@ -307,6 +314,19 @@ export class SmtpClient {
307
314
  // Private
308
315
  // -------------------------------------------------------------------------
309
316
 
317
+ /** Read the server greeting and negotiate EHLO. Shared by sendMail and verifyAuth. */
318
+ private async greetAndEhlo(
319
+ session: SmtpSession,
320
+ hostname: string,
321
+ ): Promise<{ capabilities: readonly string[] }> {
322
+ const greeting = await session.readResponse();
323
+ if (greeting.code !== 220) {
324
+ throw new Error(`SMTP unexpected greeting: ${greeting.lines.join(' | ')}`);
325
+ }
326
+ const ehlo = await session.cmd(`EHLO ${hostname}`, 250);
327
+ return { capabilities: ehlo.lines.map((l) => l.slice(4).trim().toUpperCase()) };
328
+ }
329
+
310
330
  private async authenticate(
311
331
  session: SmtpSession,
312
332
  capabilities: readonly string[],