@pellux/goodvibes-daemon 1.28.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 (113) hide show
  1. package/CHANGELOG.md +383 -0
  2. package/LICENSE +21 -0
  3. package/README.md +125 -0
  4. package/bin/goodvibes-daemon +100 -0
  5. package/bin/launcher-support.js +226 -0
  6. package/package.json +96 -0
  7. package/scripts/check-bun.sh +20 -0
  8. package/scripts/postinstall.js +244 -0
  9. package/src/cli/command-catalog.ts +828 -0
  10. package/src/cli/completion.ts +299 -0
  11. package/src/cli/help.ts +167 -0
  12. package/src/cli/index.ts +21 -0
  13. package/src/cli/parser.ts +55 -0
  14. package/src/cli/surface-catalog.ts +26 -0
  15. package/src/cli/types.ts +63 -0
  16. package/src/cluster/daemon-ws-call.ts +235 -0
  17. package/src/cluster/raw-reply-route.ts +111 -0
  18. package/src/config/checkpoint-settings.ts +113 -0
  19. package/src/config/run-daemon-config-migration.ts +47 -0
  20. package/src/config/secret-config.ts +175 -0
  21. package/src/config/secrets.ts +71 -0
  22. package/src/config/surface.ts +24 -0
  23. package/src/core/pairing-banner.ts +82 -0
  24. package/src/daemon/cli.ts +878 -0
  25. package/src/daemon/config-command.ts +281 -0
  26. package/src/daemon/handlers/context.ts +29 -0
  27. package/src/daemon/handlers/contracts.ts +43 -0
  28. package/src/daemon/handlers/credentials.ts +139 -0
  29. package/src/daemon/handlers/drafts/draft-store.ts +427 -0
  30. package/src/daemon/handlers/drafts/index.ts +17 -0
  31. package/src/daemon/handlers/drafts/register.ts +331 -0
  32. package/src/daemon/handlers/errors.ts +18 -0
  33. package/src/daemon/handlers/inbox/aggregator.ts +375 -0
  34. package/src/daemon/handlers/inbox/cursor-store.ts +512 -0
  35. package/src/daemon/handlers/inbox/index.ts +221 -0
  36. package/src/daemon/handlers/inbox/mapping.ts +192 -0
  37. package/src/daemon/handlers/inbox/poller.ts +239 -0
  38. package/src/daemon/handlers/inbox/provider-adapter.ts +171 -0
  39. package/src/daemon/handlers/inbox/providers/discord.ts +276 -0
  40. package/src/daemon/handlers/inbox/providers/email.ts +176 -0
  41. package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
  42. package/src/daemon/handlers/inbox/providers/route-util.ts +24 -0
  43. package/src/daemon/handlers/inbox/providers/slack.ts +287 -0
  44. package/src/daemon/handlers/index.ts +117 -0
  45. package/src/daemon/handlers/register.ts +180 -0
  46. package/src/daemon/handlers/remote/backends/cloud-terminal.ts +143 -0
  47. package/src/daemon/handlers/remote/backends/docker.ts +79 -0
  48. package/src/daemon/handlers/remote/backends/index.ts +40 -0
  49. package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
  50. package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
  51. package/src/daemon/handlers/remote/backends/ssh.ts +126 -0
  52. package/src/daemon/handlers/remote/backends/types.ts +97 -0
  53. package/src/daemon/handlers/remote/dispatcher.ts +181 -0
  54. package/src/daemon/handlers/remote/index.ts +120 -0
  55. package/src/daemon/handlers/remote/peer-registry.ts +357 -0
  56. package/src/daemon/handlers/remote/service.ts +191 -0
  57. package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
  58. package/src/daemon/handlers/routing/index.ts +261 -0
  59. package/src/daemon/handlers/routing/route-store.ts +319 -0
  60. package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
  61. package/src/daemon/handlers/sqlite-store.ts +303 -0
  62. package/src/daemon/handlers/triage/index.ts +57 -0
  63. package/src/daemon/handlers/triage/integration.ts +213 -0
  64. package/src/daemon/handlers/triage/pipeline.ts +274 -0
  65. package/src/daemon/handlers/triage/scorer.ts +287 -0
  66. package/src/daemon/handlers/triage/tagger/discord.ts +187 -0
  67. package/src/daemon/handlers/triage/tagger/imap.ts +384 -0
  68. package/src/daemon/handlers/triage/tagger/index.ts +184 -0
  69. package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
  70. package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
  71. package/src/daemon/handlers/triage/types.ts +50 -0
  72. package/src/daemon/lifecycle.ts +41 -0
  73. package/src/daemon/local-daemon-state.ts +233 -0
  74. package/src/daemon/pair-command.ts +301 -0
  75. package/src/daemon/provision-wake-model.ts +81 -0
  76. package/src/daemon/send/channels.ts +200 -0
  77. package/src/daemon/send/command.ts +333 -0
  78. package/src/daemon/send/composition.ts +100 -0
  79. package/src/daemon/send/failure-text.ts +93 -0
  80. package/src/daemon/send/inert-text.ts +225 -0
  81. package/src/daemon/send/stdin.ts +24 -0
  82. package/src/daemon/service-commands.ts +530 -0
  83. package/src/daemon/sessions-command.ts +209 -0
  84. package/src/daemon/status-command.ts +481 -0
  85. package/src/daemon/webui-command.ts +339 -0
  86. package/src/runtime/boot-tasks.ts +110 -0
  87. package/src/runtime/cluster-composition.ts +124 -0
  88. package/src/runtime/cluster-group-composition.ts +284 -0
  89. package/src/runtime/conversation-rewind-port.ts +171 -0
  90. package/src/runtime/credential-composition.ts +54 -0
  91. package/src/runtime/daemon-handler-composition.ts +76 -0
  92. package/src/runtime/device-posture-composition.ts +115 -0
  93. package/src/runtime/disposal-wiring.ts +101 -0
  94. package/src/runtime/fleet-needs-input-push.ts +61 -0
  95. package/src/runtime/fleet-services.ts +41 -0
  96. package/src/runtime/hosted-session-composition.ts +128 -0
  97. package/src/runtime/index.ts +100 -0
  98. package/src/runtime/knowledge-services.ts +101 -0
  99. package/src/runtime/legacy-daemon-migration.ts +605 -0
  100. package/src/runtime/legacy-daemon-reconcile.ts +448 -0
  101. package/src/runtime/mail-composition.ts +65 -0
  102. package/src/runtime/notification-dispatch.ts +86 -0
  103. package/src/runtime/plugin-composition.ts +111 -0
  104. package/src/runtime/runtime-services-types.ts +268 -0
  105. package/src/runtime/services.ts +756 -0
  106. package/src/runtime/trigger-services.ts +62 -0
  107. package/src/runtime/trust/checkpoint-eligibility.ts +138 -0
  108. package/src/runtime/trust/trust-gated-approvals.ts +169 -0
  109. package/src/runtime/update-check.ts +61 -0
  110. package/src/runtime/workspace-checkpointing.ts +116 -0
  111. package/src/testing/daemon-fixture.ts +276 -0
  112. package/src/testing/hosted-session-failures.ts +92 -0
  113. package/src/version.ts +26 -0
@@ -0,0 +1,375 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The provider-inbound aggregator behind `channels.inbox.list`.
3
+ //
4
+ // ── What it reads, and why that is the live answer ─────────────────────────
5
+ //
6
+ // It serves the daemon's SYNCED MIRROR (cursor-store.ts), not a fresh remote
7
+ // fetch per call. That is a deliberate design decision, not a shortcut:
8
+ //
9
+ // * The mirror is already live. Each adapter polls its provider on that
10
+ // provider's own cadence (Slack/Discord 30s, email 60s) and writes what it
11
+ // pulls straight into the store — that is what the triage pipeline scores.
12
+ // Reading it IS reading what has arrived.
13
+ // * A fetch-per-call would put a third-party rate limit behind a read verb
14
+ // any client may call at any rate, so one impatient UI could get every
15
+ // other consumer throttled by Slack.
16
+ // * The cluster hands FETCHING for each inbox account to one elected node
17
+ // (runtime/cluster-composition.ts). A read that fetched would make every
18
+ // standby node fetch too, which is exactly the double-read the election
19
+ // exists to prevent — while the READ is deliberately ungated so a standby
20
+ // still answers.
21
+ // * Triage scores are applied as items are persisted. Items fetched inline
22
+ // would arrive unscored, so the verb would answer two different shapes
23
+ // depending on when you called it.
24
+ // * A provider outage would turn a read into a hang or a 500 instead of a
25
+ // partial answer with a named cause.
26
+ //
27
+ // The cost of serving a mirror is that its age is not visible in the items. So
28
+ // the aggregator does not leave it implicit: `providers` reports every provider
29
+ // this node knows about on EVERY call — its state, when it last synced, how
30
+ // much of the mirror is its, and whether this node is the one fetching it.
31
+ //
32
+ // ── Honest partial results ────────────────────────────────────────────────
33
+ //
34
+ // A provider whose last sync failed contributes no items AND says so, and the
35
+ // answer's `partial` flag is true. The failure mode this rules out is the one
36
+ // where a UI renders four Slack messages and no mail, and nobody can tell
37
+ // whether the mailbox is quiet or broken.
38
+ // ---------------------------------------------------------------------------
39
+
40
+ import type { InboxCursorStore, InboxPosition } from './cursor-store.ts';
41
+ import type { InboundPoller, ProviderStatus } from './poller.ts';
42
+ import type { InboundChannelItem } from './provider-adapter.ts';
43
+ import { HandlerError } from '../errors.ts';
44
+
45
+ export const DEFAULT_LIMIT = 50;
46
+ export const MAX_LIMIT = 500;
47
+
48
+ /** SDK `channels.inbox.list` input. */
49
+ export interface InboxListInput {
50
+ provider?: string;
51
+ limit?: number;
52
+ since?: number;
53
+ cursor?: string;
54
+ }
55
+
56
+ /** One item in the SDK CHANNEL_INBOX_ITEM_SCHEMA wire shape. */
57
+ export interface ChannelInboxItem {
58
+ id: string;
59
+ provider: string;
60
+ kind: string;
61
+ /** Redacted sender token (sha256First, 16 hex). Never the raw id. */
62
+ from: string;
63
+ subject?: string;
64
+ bodyPreview: string;
65
+ receivedAt: number;
66
+ unread: boolean;
67
+ routeId?: string;
68
+ }
69
+
70
+ /**
71
+ * Per-provider standing, one entry per provider this node knows about, on every
72
+ * call. See CHANNEL_INBOX_PROVIDER_STATUS_SCHEMA in the SDK catalog for the
73
+ * meaning of each `state`.
74
+ */
75
+ export interface ChannelInboxProviderStatus {
76
+ provider: string;
77
+ state: 'ready' | 'empty' | 'unconfigured' | 'error' | 'pending';
78
+ /** Items this provider contributed to the page being returned. */
79
+ itemCount: number;
80
+ /** Items this provider holds in the mirror under the same filter. */
81
+ storedCount: number;
82
+ configured?: boolean;
83
+ lastSyncAt?: number;
84
+ /** Whether THIS node is currently fetching this provider. */
85
+ syncing?: boolean;
86
+ error?: string;
87
+ }
88
+
89
+ /** SDK `channels.inbox.list` output. */
90
+ export interface InboxListOutput {
91
+ items: ChannelInboxItem[];
92
+ total: number;
93
+ truncated: boolean;
94
+ hasMore: boolean;
95
+ cursor?: string;
96
+ nextCursor?: string;
97
+ providers: ChannelInboxProviderStatus[];
98
+ partial: boolean;
99
+ }
100
+
101
+ /** The normalized read the aggregator performs. */
102
+ export interface InboxListQuery {
103
+ providers?: string[];
104
+ limit: number;
105
+ since?: number;
106
+ after?: InboxPosition;
107
+ }
108
+
109
+ /** Everything the aggregator reads from. Both are already-built collaborators. */
110
+ export interface InboxAggregatorSources {
111
+ readonly store: InboxCursorStore;
112
+ readonly poller: InboundPoller;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Page cursors
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * A page cursor is the position of the last item handed out, base64url-encoded
121
+ * so it reads as opaque and callers do not build one by hand. It is NOT the
122
+ * `cursor` field in the answer — that one is the freshness watermark a caller
123
+ * feeds back as `since`. Two different questions ("what is new" vs "the next
124
+ * page down"), so two different values; collapsing them is how a paging client
125
+ * ends up silently re-reading page one forever.
126
+ */
127
+ export function encodePageCursor(position: InboxPosition): string {
128
+ return Buffer.from(`${position.receivedAt}:${position.id}`, 'utf8').toString('base64url');
129
+ }
130
+
131
+ /**
132
+ * Decode a page cursor, or refuse. A malformed cursor is a 400 naming the
133
+ * field, never a silently-ignored one: quietly restarting from the top would
134
+ * hand a paging caller the same first page forever and look like data loss
135
+ * further down.
136
+ */
137
+ export function decodePageCursor(raw: string): InboxPosition {
138
+ let decoded = '';
139
+ try {
140
+ decoded = Buffer.from(raw, 'base64url').toString('utf8');
141
+ } catch {
142
+ throw invalidCursor();
143
+ }
144
+ const split = decoded.indexOf(':');
145
+ if (split <= 0) throw invalidCursor();
146
+ const receivedAt = Number(decoded.slice(0, split));
147
+ const id = decoded.slice(split + 1);
148
+ if (!Number.isFinite(receivedAt) || receivedAt < 0 || id.length === 0) throw invalidCursor();
149
+ return { receivedAt: Math.floor(receivedAt), id };
150
+ }
151
+
152
+ function invalidCursor(): HandlerError {
153
+ return new HandlerError(
154
+ 'cursor is not a cursor this method issued; pass back a nextCursor value verbatim, or omit it to start from the newest item',
155
+ 'INVALID_ARGUMENT',
156
+ 400,
157
+ );
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Input
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Normalize the invocation's params.
166
+ *
167
+ * Both sources are read because both are real: the advertised REST path is a
168
+ * GET, so `?limit=10` arrives as a query STRING, while a methodId invoke can
169
+ * carry the same field as a number in the body. A handler that read only the
170
+ * body would answer the default page to every plain-REST caller and look like
171
+ * it was ignoring them.
172
+ */
173
+ export function normalizeInboxQuery(
174
+ body: unknown,
175
+ query: Readonly<Record<string, string>> = {},
176
+ ): InboxListQuery {
177
+ const fromBody = (body ?? {}) as InboxListInput;
178
+ const provider = firstString(query.provider, fromBody.provider);
179
+ const limitRaw = firstNumber(query.limit, fromBody.limit);
180
+ const sinceRaw = firstNumber(query.since, fromBody.since);
181
+ const cursor = firstString(query.cursor, fromBody.cursor);
182
+
183
+ const limit = limitRaw === undefined
184
+ ? DEFAULT_LIMIT
185
+ : Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIMIT);
186
+
187
+ return {
188
+ ...(provider ? { providers: [provider] } : {}),
189
+ limit,
190
+ ...(sinceRaw !== undefined && sinceRaw >= 0 ? { since: Math.floor(sinceRaw) } : {}),
191
+ ...(cursor ? { after: decodePageCursor(cursor) } : {}),
192
+ };
193
+ }
194
+
195
+ function firstString(...candidates: (string | undefined)[]): string | undefined {
196
+ for (const candidate of candidates) {
197
+ if (typeof candidate === 'string' && candidate.length > 0) return candidate;
198
+ }
199
+ return undefined;
200
+ }
201
+
202
+ function firstNumber(...candidates: (string | number | undefined)[]): number | undefined {
203
+ for (const candidate of candidates) {
204
+ if (candidate === undefined || candidate === null || candidate === '') continue;
205
+ const value = Number(candidate);
206
+ if (Number.isFinite(value)) return value;
207
+ }
208
+ return undefined;
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // The aggregate read
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /** Map a daemon-internal item onto the SDK CHANNEL_INBOX_ITEM_SCHEMA wire shape. */
216
+ export function toWireItem(item: InboundChannelItem): ChannelInboxItem {
217
+ const wire: ChannelInboxItem = {
218
+ id: item.id,
219
+ provider: item.provider,
220
+ kind: item.kind,
221
+ from: item.fromDigest,
222
+ bodyPreview: item.bodyPreview,
223
+ receivedAt: item.receivedAt,
224
+ unread: item.unread,
225
+ };
226
+ if (item.subjectPreview.length > 0) wire.subject = item.subjectPreview;
227
+ if (item.routeId != null) wire.routeId = item.routeId;
228
+ return wire;
229
+ }
230
+
231
+ /**
232
+ * Merge the mirror into one bounded, paginated page and attach every provider's
233
+ * standing to it.
234
+ *
235
+ * The merge itself is the store's ordering (receivedAt DESC, id ASC across all
236
+ * providers), so items interleave by arrival rather than being grouped by
237
+ * provider — an inbox is a timeline, and each item carries its own `provider`
238
+ * so attribution survives the merge.
239
+ */
240
+ export function aggregateInbox(
241
+ sources: InboxAggregatorSources,
242
+ query: InboxListQuery,
243
+ ): InboxListOutput {
244
+ const { store, poller } = sources;
245
+ const providers = query.providers;
246
+
247
+ // One row past the page. `hasMore` then reports what the store actually
248
+ // holds rather than being inferred from "the page came back full", which is
249
+ // wrong exactly when the last page is full.
250
+ const fetched = store.listItems({
251
+ ...(providers ? { providers } : {}),
252
+ ...(query.since !== undefined ? { since: query.since } : {}),
253
+ ...(query.after ? { after: query.after } : {}),
254
+ limit: query.limit + 1,
255
+ });
256
+ const hasMore = fetched.length > query.limit;
257
+ const pageItems = hasMore ? fetched.slice(0, query.limit) : fetched;
258
+ const items = pageItems.map(toWireItem);
259
+
260
+ const total = store.countItems(providers, query.since);
261
+ const storedByProvider = store.countItemsByProvider(providers, query.since);
262
+ const pageByProvider = new Map<string, number>();
263
+ for (const item of pageItems) {
264
+ pageByProvider.set(item.provider, (pageByProvider.get(item.provider) ?? 0) + 1);
265
+ }
266
+
267
+ const statuses = describeProviders({
268
+ poller,
269
+ requested: providers,
270
+ storedByProvider,
271
+ pageByProvider,
272
+ });
273
+
274
+ const output: InboxListOutput = {
275
+ items,
276
+ total,
277
+ // Retained spelling of hasMore. Both are set and always agree; the old name
278
+ // is what the already-shipped agent-side reader looks at.
279
+ truncated: hasMore,
280
+ hasMore,
281
+ providers: statuses,
282
+ // Only a CONFIGURED provider's failure makes the answer partial. An
283
+ // unconfigured one is not hiding anything.
284
+ partial: statuses.some((status) => status.state === 'error'),
285
+ };
286
+
287
+ const watermark = store.maxReceivedAt(providers);
288
+ const nextSince = Math.max(query.since ?? 0, watermark);
289
+ if (nextSince > 0) output.cursor = String(nextSince);
290
+
291
+ const last = pageItems[pageItems.length - 1];
292
+ if (hasMore && last) {
293
+ output.nextCursor = encodePageCursor({ receivedAt: last.receivedAt, id: last.id });
294
+ }
295
+
296
+ return output;
297
+ }
298
+
299
+ /**
300
+ * Turn the poller's per-provider record into the wire statuses.
301
+ *
302
+ * Every provider the node has an adapter for appears, including ones that
303
+ * contributed nothing and ones nobody has configured — a provider missing from
304
+ * this list would be a hole the caller could not even see.
305
+ */
306
+ function describeProviders(input: {
307
+ poller: InboundPoller;
308
+ requested: readonly string[] | undefined;
309
+ storedByProvider: Map<string, number>;
310
+ pageByProvider: Map<string, number>;
311
+ }): ChannelInboxProviderStatus[] {
312
+ const { poller, requested, storedByProvider, pageByProvider } = input;
313
+ const known = poller.snapshotStatuses(requested);
314
+
315
+ // A `?provider=` filter naming something this node has no adapter for gets an
316
+ // empty list and no status, which reads as "we have nothing" — so it is
317
+ // reported explicitly instead.
318
+ const reported = new Set(known.map((status) => status.id));
319
+ const unknownRequested = (requested ?? []).filter((id) => !reported.has(id));
320
+
321
+ const out: ChannelInboxProviderStatus[] = known.map((status) => {
322
+ const storedCount = storedByProvider.get(status.id) ?? 0;
323
+ const wire: ChannelInboxProviderStatus = {
324
+ provider: status.id,
325
+ state: wireState(status, storedCount),
326
+ itemCount: pageByProvider.get(status.id) ?? 0,
327
+ storedCount,
328
+ syncing: poller.isProviderRunning(status.id),
329
+ };
330
+ if (status.configured !== undefined) wire.configured = status.configured;
331
+ if (status.lastPolledAt !== undefined) wire.lastSyncAt = status.lastPolledAt;
332
+ // The error belongs to the `error` state only. Carrying the "missing
333
+ // credential" text on an `unconfigured` entry would read as a fault, and
334
+ // it is not one.
335
+ if (wire.state === 'error' && status.error !== undefined) wire.error = status.error;
336
+ return wire;
337
+ });
338
+
339
+ for (const id of unknownRequested) {
340
+ out.push({
341
+ provider: id,
342
+ state: 'unconfigured',
343
+ itemCount: 0,
344
+ storedCount: storedByProvider.get(id) ?? 0,
345
+ configured: false,
346
+ syncing: false,
347
+ });
348
+ }
349
+
350
+ return out;
351
+ }
352
+
353
+ /**
354
+ * Map the poller's internal state onto the wire vocabulary.
355
+ *
356
+ * The internal 'unavailable' covers two situations the wire deliberately keeps
357
+ * apart — nothing configured, versus configured and failing — because a caller
358
+ * does something different about each.
359
+ *
360
+ * ready-vs-empty is decided by the MIRROR, not by the last poll. The poller's
361
+ * 'ready' means "that fetch brought something in", which goes back to 'empty'
362
+ * on the next quiet tick even though the items are still sitting there; a
363
+ * reader of this verb is asking whether this provider has anything to show,
364
+ * so the stored count is what answers it.
365
+ */
366
+ function wireState(
367
+ status: ProviderStatus,
368
+ storedCount: number,
369
+ ): ChannelInboxProviderStatus['state'] {
370
+ if (!status.polled) return 'pending';
371
+ if (status.state === 'unavailable') {
372
+ return status.configured === false ? 'unconfigured' : 'error';
373
+ }
374
+ return storedCount > 0 ? 'ready' : 'empty';
375
+ }