@hraness/ghostget 0.18.13 → 0.18.15

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 (35) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +12 -7
  3. package/dist/apple-photos-client.js +1 -1
  4. package/dist/beeper-client.js +42 -6
  5. package/dist/{imessage-direct-install-2gm3kge7.js → imessage-direct-install-5nftwc6p.js} +1 -1
  6. package/dist/{index-maa9hb5z.js → index-4tsyncva.js} +1 -1
  7. package/dist/{index-en5hycxp.js → index-kaav16fd.js} +4 -2
  8. package/dist/{index-2ymnp8xv.js → index-qcf2f6wm.js} +5 -1
  9. package/dist/{messaging-automation-j4274hvc.js → messaging-automation-58s1366z.js} +1 -1
  10. package/dist/messaging-automation-api.js +2 -2
  11. package/dist/{messaging-native-install-8w1j36ah.js → messaging-native-install-c90nv501.js} +1 -1
  12. package/dist/{whatsapp-automation-runtime-hgh1e9rm.js → whatsapp-automation-runtime-ke1dx91p.js} +1 -1
  13. package/docs/messaging-automation.md +14 -7
  14. package/package.json +8 -4
  15. package/skills/ghostget/references/install.md +5 -5
  16. package/src/assets/adapters/beeper/wrench-web-adapter.json +37 -1
  17. package/src/assets/adapters/beeper/wrench-web-adapter.v2.4.0.json +1500 -0
  18. package/src/beeper-client-types.ts +1 -1
  19. package/src/control/menubar-cli.ts +3 -1
  20. package/src/control/menubar-icon.ts +4 -0
  21. package/src/messaging-automation-descriptors.ts +9 -1
  22. package/src/messaging-automation-factory.ts +7 -5
  23. package/src/messaging-automation-server.ts +2 -2
  24. package/src/messaging-automation-types.ts +3 -2
  25. package/src/messaging-automation-validation.ts +5 -1
  26. package/src/plugins/beeper-linked-device/plugin.ts +3 -1
  27. package/src/provider-plugin-contract-identity.ts +11 -2
  28. package/src/providers/beeper-automation.ts +447 -0
  29. package/src/providers/beeper-local-runtime.ts +61 -21
  30. package/src/providers/beeper-local.ts +4 -4
  31. package/src/providers/beeper-omni.ts +9 -2
  32. package/src/providers/messaging-native-install.ts +2 -1
  33. package/src/providers/x-web-runtime.ts +11 -11
  34. package/src/providers/x-web.ts +36 -35
  35. package/src/version.ts +1 -1
@@ -0,0 +1,447 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { GhostgetAuth } from "../auth";
3
+ import { canonicalJson } from "../canonical-json";
4
+ import type { LocalCliExecutionOptions } from "../local-cli-execution";
5
+ import type { LocalCliRecipe, OperationInput } from "../model";
6
+ import type { AutomationAction, AutomationConversation, AutomationIdentity, AutomationMessage, AutomationProviderPage, AutomationProviderStatus, AutomationProviderSendResult, MessagingAutomationProvider } from "../messaging-automation-types";
7
+ import { AUTOMATION_ACTION_KINDS, automationArray, automationInteger, automationRecord, automationText, parseAutomationAction, parseAutomationIdentity } from "../messaging-automation-validation";
8
+ import { OperationDeadline } from "../operation-deadline";
9
+ import { executeBeeperDirectMessagingPart, executeBeeperLocalOperation, type BeeperDirectDependencies, type BeeperDirectMessagingDependencies, type BeeperLocalRuntimeDependencies } from "./beeper-local-runtime";
10
+ import { materializeBeeperExactConversation, materializeBeeperMessagingList, materializeBeeperMessagingRead, rawBeeperConversationId, rawBeeperMessageId } from "./beeper-omni";
11
+
12
+ export type BeeperAutomationOperation = "inspect" | "conversations" | "resolve" | "history" | "events" | AutomationAction["kind"];
13
+ export type BeeperAutomationAdmission = Readonly<{ auth: GhostgetAuth; accountIdentity: string; implementationIdentity: string }>;
14
+ export type BeeperAutomationOptions = Readonly<{
15
+ authorize(operation: BeeperAutomationOperation, signal?: AbortSignal): Promise<BeeperAutomationAdmission>;
16
+ execution: Pick<LocalCliExecutionOptions, "registerCleanupBarrier" | "environment">;
17
+ /** Internal deterministic test seams, never populated from public input. */
18
+ dependencies?: BeeperLocalRuntimeDependencies;
19
+ directDependencies?: BeeperDirectDependencies;
20
+ messagingDependencies?: BeeperDirectMessagingDependencies;
21
+ }>;
22
+
23
+ const sha = (value: unknown): string => createHash("sha256").update(canonicalJson(value)).digest("hex");
24
+
25
+ /** Recipes mirror the pinned beeper adapter manifest contracts exactly. */
26
+ const RECIPES = Object.freeze({
27
+ accounts: Object.freeze({ surface: "beeper", action: "accounts.list", contractVersion: 2, timeoutMs: 120_000, maxOutputBytes: 10_485_760 }),
28
+ conversations: Object.freeze({ surface: "beeper", action: "messaging.list", contractVersion: 1, timeoutMs: 120_000, maxOutputBytes: 10_485_760 }),
29
+ conversation: Object.freeze({ surface: "beeper", action: "conversations.read", contractVersion: 2, timeoutMs: 120_000, maxOutputBytes: 10_485_760 }),
30
+ messages: Object.freeze({ surface: "beeper", action: "messaging.read", contractVersion: 3, timeoutMs: 120_000, maxOutputBytes: 10_485_760 }),
31
+ } as const satisfies Record<string, LocalCliRecipe>);
32
+
33
+ type Coordinate = Readonly<{ provider: "beeper"; accountId: string; conversationId: string }>;
34
+ function coordinate(value: unknown): Coordinate {
35
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Beeper automation coordinate is invalid.");
36
+ const r = automationRecord(value, ["provider", "accountId", "conversationId"]);
37
+ if (r.provider !== "beeper") throw new Error("Beeper cannot operate another messaging network");
38
+ return Object.freeze({ provider: "beeper", accountId: automationText(r.accountId, 512), conversationId: automationText(r.conversationId, 2048) });
39
+ }
40
+
41
+ /** Automation message ids stay inside the strict identifier alphabet; raw
42
+ * Beeper ids can exceed it, so long or unusual ids are deterministically hashed. */
43
+ function automationMessageId(raw: string): string {
44
+ const id = automationText(raw, 2048);
45
+ if (/^[A-Za-z0-9._:-]{1,256}$/u.test(id)) return id;
46
+ return `beeper-${sha(id)}`;
47
+ }
48
+
49
+ function rawAccountFromProviderId(providerId: string): string {
50
+ const match = /^beeper:([A-Za-z0-9_-]+):chat:[A-Za-z0-9_-]+$/u.exec(providerId);
51
+ if (match === null) throw new Error("Normalized Beeper conversation ID is malformed.");
52
+ const raw = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(match[1]!, "base64url"));
53
+ if (Buffer.from(raw, "utf8").toString("base64url") !== match[1]) throw new Error("Normalized Beeper conversation ID is not canonically encoded.");
54
+ return raw;
55
+ }
56
+
57
+ function rawUserFromProviderId(providerId: string): string | null {
58
+ const match = /^beeper:[A-Za-z0-9_-]+:user:([A-Za-z0-9_-]+)$/u.exec(providerId);
59
+ if (match === null) return null;
60
+ try { return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(match[1]!, "base64url")); } catch { return null; }
61
+ }
62
+
63
+ type AccountsSnapshot = Readonly<{ subject: string; accounts: readonly Readonly<{ accountId: string; userId: string | null; isSelf: boolean | null }>[] }>;
64
+
65
+ function snapshotIdentity(admission: BeeperAutomationAdmission, snapshot: AccountsSnapshot): AutomationIdentity {
66
+ return parseAutomationIdentity({
67
+ provider: "beeper", authId: admission.auth.id,
68
+ accountIdentity: admission.accountIdentity,
69
+ accountSubject: snapshot.subject,
70
+ implementationIdentity: admission.implementationIdentity,
71
+ // Stable realm membership only; live per-bridge statuses flap without
72
+ // changing the identity that enrollments and cursors must bind.
73
+ sourceGeneration: sha(snapshot.accounts.map(account => ({ accountId: account.accountId, userId: account.userId, isSelf: account.isSelf }))),
74
+ });
75
+ }
76
+
77
+ function conversation(entity: Readonly<Record<string, unknown>>): AutomationConversation {
78
+ const providerId = automationText(entity.providerId, 4096);
79
+ const accountId = rawAccountFromProviderId(providerId);
80
+ const rawConversationId = rawBeeperConversationId(accountId, providerId);
81
+ const kind = entity.conversationKind === "single" ? "single" : entity.conversationKind === "group" ? "group" : "unknown";
82
+ const participants = automationArray(entity.participants, 500)
83
+ .map(participant => rawUserFromProviderId(automationText((participant as Readonly<Record<string, unknown>>).providerId, 4096)))
84
+ .filter((participant): participant is string => participant !== null);
85
+ const unique = [...new Set(participants)].sort();
86
+ if (kind === "single" && unique.length > 2) return Object.freeze({
87
+ coordinate: Object.freeze({ provider: "beeper", accountId, conversationId: rawConversationId }),
88
+ title: null, kind: "unknown", participants: Object.freeze([]),
89
+ });
90
+ return Object.freeze({
91
+ coordinate: Object.freeze({ provider: "beeper", accountId, conversationId: rawConversationId }),
92
+ title: entity.title === null || entity.title === undefined ? null : automationText(entity.title, 512),
93
+ kind, participants: Object.freeze(unique),
94
+ });
95
+ }
96
+
97
+ /** One message observation. `sortKey` orders the feed; the materialized entity
98
+ * carries the reviewed projection fields. */
99
+ type ObservedMessage = Readonly<{ entity: Readonly<Record<string, unknown>>; id: string; sortKey: string }>;
100
+
101
+ function projectMessage(selected: Coordinate, observed: ObservedMessage): AutomationMessage {
102
+ const entity = observed.entity;
103
+ const body = entity.body === null || entity.body === undefined ? null : automationText(entity.body, 1_048_576, true);
104
+ let text = body;
105
+ if (text !== null && Buffer.byteLength(text) > 65_536) {
106
+ let cut = 65_500;
107
+ while (cut > 0 && (Buffer.from(text).subarray(cut, cut + 1)[0]! & 0xc0) === 0x80) cut -= 1;
108
+ text = `${Buffer.from(text).subarray(0, cut).toString("utf8")}…`;
109
+ }
110
+ const state = automationText(entity.state, 64);
111
+ const deleted = state !== "active";
112
+ const edited = entity.providerRevision !== null && entity.providerRevision !== undefined;
113
+ const attachments = deleted ? [] : automationArray(entity.attachments, 20).map(item => {
114
+ const a = item as Readonly<Record<string, unknown>>;
115
+ return Object.freeze({
116
+ name: a.name === null || a.name === undefined ? null : automationText(a.name, 512),
117
+ mimeType: a.mimeType === null || a.mimeType === undefined ? null : automationText(a.mimeType, 256),
118
+ sizeBytes: a.sizeBytes === null || a.sizeBytes === undefined ? null : automationInteger(a.sizeBytes, 0, 1024 * 1024 * 1024),
119
+ });
120
+ });
121
+ if (deleted) text = null;
122
+ const reply = entity.replyToProviderId === null || entity.replyToProviderId === undefined
123
+ ? null
124
+ : automationMessageId(rawBeeperMessageId(selected.accountId, automationText(entity.replyToProviderId, 4096)));
125
+ return Object.freeze({
126
+ id: automationMessageId(observed.id),
127
+ coordinate: Object.freeze({ provider: "beeper", accountId: selected.accountId, conversationId: selected.conversationId }),
128
+ direction: entity.direction === "outgoing" || entity.direction === "incoming" ? entity.direction : "unknown",
129
+ occurredAt: automationText(entity.orderedAt, 32),
130
+ text, kind: state !== "active" ? "delete" : edited ? "edit" : "message",
131
+ relatedMessageId: reply, attachments: Object.freeze(attachments),
132
+ });
133
+ }
134
+
135
+ /** Feed cursor per coordinate. `mark` is the newest emitted sortKey and only
136
+ * advances to emitted positions. `floor` non-null means a drain is active: the
137
+ * interval (`floor`, `mark`] still has unemitted messages below the newest
138
+ * window. `window` is the before-cursor of the window being drained (`null` =
139
+ * the newest page) and `drained` is the last emitted sortKey inside it, so a
140
+ * partially emitted window resumes exactly. */
141
+ type CoordinateState = Readonly<{ mark: string | null; floor: string | null; window: string | null; drained: string | null }>;
142
+ type FeedCursor = Readonly<{ version: 1; identity: string; scope: string; states: Readonly<Record<string, CoordinateState>> }>;
143
+ const coordinateKey = (selected: Coordinate): string => sha([selected.accountId, selected.conversationId]);
144
+
145
+ function encodeCursor(current: AutomationIdentity, coordinates: readonly Coordinate[], states: Readonly<Record<string, CoordinateState>>): string {
146
+ return Buffer.from(canonicalJson({ version: 1, identity: sha(current), scope: sha(coordinates), states }), "utf8").toString("base64url");
147
+ }
148
+ function parseCursor(value: string | null, current: AutomationIdentity, coordinates: readonly Coordinate[]): FeedCursor {
149
+ const initial = { version: 1 as const, identity: sha(current), scope: sha(coordinates) };
150
+ if (value === null) return Object.freeze({ ...initial, states: Object.freeze({}) });
151
+ automationText(value, 16_384);
152
+ const decoded = Buffer.from(value, "base64url");
153
+ if (decoded.toString("base64url") !== value) throw new Error("Noncanonical Beeper cursor");
154
+ const row = automationRecord(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(decoded)), ["version", "identity", "scope", "states"]);
155
+ if (row.version !== 1 || row.identity !== initial.identity || row.scope !== initial.scope) throw new Error("Beeper cursor identity or scope changed");
156
+ const stateKeys = Object.keys(row.states as object);
157
+ if (stateKeys.length > 50) throw new Error("Beeper cursor carries too many scopes");
158
+ const rawStates = automationRecord(row.states, stateKeys);
159
+ const states: Record<string, CoordinateState> = {};
160
+ for (const [key, raw] of Object.entries(rawStates)) {
161
+ const state = automationRecord(raw, ["mark", "floor", "window", "drained"]);
162
+ states[key] = Object.freeze({
163
+ mark: state.mark === null ? null : automationText(state.mark, 2048),
164
+ floor: state.floor === null ? null : automationText(state.floor, 2048),
165
+ window: state.window === null ? null : automationText(state.window, 2048),
166
+ drained: state.drained === null ? null : automationText(state.drained, 2048),
167
+ });
168
+ }
169
+ return Object.freeze({ ...initial, states: Object.freeze(states) });
170
+ }
171
+
172
+ /** Concrete pinned Beeper linked-device adapter over the reviewed local CLI and
173
+ * Desktop loopback contracts. No push feed exists, so events are honest bounded
174
+ * polling: a newest-page watermark plus a replayable before-cursor drain for any
175
+ * window that outgrows one page. */
176
+ export function createBeeperAutomationProvider(options: BeeperAutomationOptions): MessagingAutomationProvider {
177
+ const lifetime = new AbortController();
178
+ let closed = false, inFlight: Promise<unknown> | undefined;
179
+
180
+ async function run<T>(operation: BeeperAutomationOperation, signal: AbortSignal | undefined, work: (admission: BeeperAutomationAdmission, signal: AbortSignal) => Promise<T>): Promise<T> {
181
+ if (closed || inFlight) throw new Error("Beeper automation provider is closed or busy");
182
+ const activeSignal = signal ? AbortSignal.any([signal, lifetime.signal]) : lifetime.signal;
183
+ const pending = (async () => { activeSignal.throwIfAborted(); const before = await options.authorize(operation, activeSignal); if (typeof before.auth.subject !== "string" || before.auth.subject.length === 0) throw new Error("Beeper automation requires a bound local account realm"); const result = await work(before, activeSignal); const after = await options.authorize(operation, activeSignal); if (sha(before) !== sha(after)) throw new Error("Beeper account or permission changed during the operation"); return result; })();
184
+ inFlight = pending; try { return await pending; } finally { if (inFlight === pending) inFlight = undefined; }
185
+ }
186
+
187
+ async function execute(recipe: LocalCliRecipe, input: OperationInput, auth: GhostgetAuth, signal: AbortSignal): Promise<unknown> {
188
+ const execution = await executeBeeperLocalOperation(recipe, input, auth, {
189
+ signal,
190
+ ...(options.execution.environment === undefined ? {} : { environment: options.execution.environment }),
191
+ ...(options.execution.registerCleanupBarrier === undefined ? {} : { registerCleanupBarrier: options.execution.registerCleanupBarrier }),
192
+ ...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
193
+ ...(options.directDependencies === undefined ? {} : { directDependencies: options.directDependencies }),
194
+ });
195
+ if (execution.status !== "succeeded") throw new Error(`Beeper ${recipe.action} did not succeed.`);
196
+ return execution.output;
197
+ }
198
+
199
+ async function accountsSnapshot(admission: BeeperAutomationAdmission, signal: AbortSignal): Promise<AccountsSnapshot> {
200
+ const output = automationRecord(await execute(RECIPES.accounts, {}, admission.auth, signal), ["provider", "operation", "accountSubject", "accounts"]);
201
+ const subject = automationText(output.accountSubject, 512);
202
+ const accounts = automationArray(output.accounts, 128).map(value => {
203
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Beeper account projection is invalid.");
204
+ const account = value as Record<string, unknown>;
205
+ const user = account.user;
206
+ if (typeof user !== "object" || user === null || Array.isArray(user)) throw new Error("Beeper account user projection is invalid.");
207
+ const record = user as Record<string, unknown>;
208
+ return Object.freeze({
209
+ accountId: automationText(account.accountId, 512),
210
+ userId: automationText(record.id, 512),
211
+ isSelf: record.isSelf === null || record.isSelf === undefined ? null : record.isSelf === true,
212
+ });
213
+ });
214
+ return Object.freeze({ subject, accounts: Object.freeze(accounts) });
215
+ }
216
+
217
+ function offlineIdentity(admission: BeeperAutomationAdmission): AutomationIdentity {
218
+ return parseAutomationIdentity({
219
+ provider: "beeper", authId: admission.auth.id, accountIdentity: admission.accountIdentity,
220
+ accountSubject: admission.auth.subject ?? "unreachable",
221
+ implementationIdentity: admission.implementationIdentity, sourceGeneration: "unreachable",
222
+ });
223
+ }
224
+
225
+ function status(current: AutomationIdentity, connected: boolean): AutomationProviderStatus {
226
+ const actions = Object.fromEntries(AUTOMATION_ACTION_KINDS.map(kind => [kind, {
227
+ available: connected && kind === "text",
228
+ reason: kind !== "text"
229
+ ? "The pinned Beeper local transport has no admitted operation for this experience."
230
+ : connected ? null : "Beeper Desktop is not reachable on the reviewed local endpoint.",
231
+ }])) as AutomationProviderStatus["actions"];
232
+ return Object.freeze({
233
+ identity: current, connected,
234
+ events: Object.freeze({ available: connected, reason: connected ? null : "Beeper Desktop is not reachable on the reviewed local endpoint." }),
235
+ actions,
236
+ });
237
+ }
238
+
239
+ type ReadPage = Readonly<{ messages: readonly ObservedMessage[]; continuation: string | null }>;
240
+
241
+ /** Internal read bound. The runtime refuses a limit smaller than one provider
242
+ * page (it cannot prove a page-internal skip), so reads always collect a full
243
+ * contract-sized window and callers slice the visible share. */
244
+ const WINDOW_LIMIT = 200;
245
+
246
+ /** Newest-first window read; `beforeCursor === null` reads the newest page. */
247
+ async function readMessages(admission: BeeperAutomationAdmission, selected: Coordinate, beforeCursor: string | null, signal: AbortSignal): Promise<Readonly<{ page: ReadPage; output: Record<string, unknown> }>> {
248
+ const operationInput: OperationInput = {
249
+ account_id: selected.accountId, conversation_id: selected.conversationId,
250
+ ...(beforeCursor === null ? {} : { before_cursor: beforeCursor }),
251
+ limit: WINDOW_LIMIT,
252
+ };
253
+ const output = automationRecord(await execute(RECIPES.messages, operationInput, admission.auth, signal), ["provider", "operation", "accountSubject", "projection", "accountId", "conversationId", "selfUserId", "canonicalSelfUserId", "requestCursor", "requestDirection", "requestedSender", "messages", "tombstones", "continuation", "completeness"]);
254
+ const materialized = materializeBeeperMessagingRead(operationInput, output);
255
+ const rawMessages = automationArray(output.messages, WINDOW_LIMIT);
256
+ if (rawMessages.length !== materialized.entities.length) throw new Error("Beeper message projection count drifted.");
257
+ const observed: ObservedMessage[] = materialized.entities.map((entity, index) => {
258
+ const raw = rawMessages[index];
259
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new Error("Beeper message projection is invalid.");
260
+ const record = raw as Record<string, unknown>;
261
+ const id = automationText(record.id, 2048);
262
+ if (rawBeeperMessageId(selected.accountId, (entity as Readonly<Record<string, unknown>>).providerId) !== id) throw new Error("Beeper message projection does not bind its raw record.");
263
+ return Object.freeze({ entity: entity as Readonly<Record<string, unknown>>, id, sortKey: automationText(record.sortKey, 1024) });
264
+ });
265
+ const continuation = output.continuation === null ? null : automationRecord(output.continuation, ["direction", "cursor"]);
266
+ return Object.freeze({
267
+ page: Object.freeze({
268
+ messages: Object.freeze(observed),
269
+ continuation: continuation === null ? null : automationText(continuation.cursor, 2048),
270
+ }),
271
+ output: output as Record<string, unknown>,
272
+ });
273
+ }
274
+
275
+ return {
276
+ provider: "beeper",
277
+ inspect(signal) {
278
+ return run("inspect", signal, async (admission, active) => {
279
+ try {
280
+ const snapshot = await accountsSnapshot(admission, active);
281
+ return status(snapshotIdentity(admission, snapshot), true);
282
+ } catch {
283
+ return status(offlineIdentity(admission), false);
284
+ }
285
+ });
286
+ },
287
+ conversations(input, signal) {
288
+ const limit = automationInteger(input.limit, 1, 200);
289
+ return run("conversations", signal, async (admission, active) => {
290
+ const snapshot = await accountsSnapshot(admission, active);
291
+ const output = await execute(RECIPES.conversations, { limit }, admission.auth, active);
292
+ const materialized = materializeBeeperMessagingList({ limit }, output);
293
+ const completeness = typeof output === "object" && output !== null
294
+ ? (output as Readonly<Record<string, unknown>>).completeness
295
+ : undefined;
296
+ if (typeof completeness !== "object" || completeness === null || typeof (completeness as Readonly<Record<string, unknown>>).requestedLimitReached !== "boolean") throw new Error("Beeper conversation list completeness is invalid.");
297
+ const requestedLimitReached = (completeness as Readonly<Record<string, unknown>>).requestedLimitReached === true;
298
+ return Object.freeze({
299
+ identity: snapshotIdentity(admission, snapshot),
300
+ conversations: Object.freeze(materialized.entities.map(entity => conversation(entity as Readonly<Record<string, unknown>>))),
301
+ // The pinned CLI list output has no continuation metadata; an exactly
302
+ // full remote window cannot prove completeness, even when out-of-realm
303
+ // rows were excluded from the projection.
304
+ complete: !requestedLimitReached,
305
+ });
306
+ });
307
+ },
308
+ resolve(value, signal) {
309
+ const selected = coordinate(value);
310
+ return run("resolve", signal, async (admission, active) => {
311
+ const snapshot = await accountsSnapshot(admission, active);
312
+ const operationInput = { account_id: selected.accountId, conversation_id: selected.conversationId };
313
+ const entity = materializeBeeperExactConversation(operationInput, await execute(RECIPES.conversation, operationInput, admission.auth, active));
314
+ const projected = conversation(entity as Readonly<Record<string, unknown>>);
315
+ if (canonicalJson(projected.coordinate) !== canonicalJson(selected)) throw new Error("Beeper route resolution changed target.");
316
+ return Object.freeze({ identity: snapshotIdentity(admission, snapshot), conversation: projected });
317
+ });
318
+ },
319
+ history(input, signal) {
320
+ const selected = coordinate(input.coordinate), limit = automationInteger(input.limit, 1, 200);
321
+ return run("history", signal, async (admission, active) => {
322
+ const snapshot = await accountsSnapshot(admission, active);
323
+ const current = snapshotIdentity(admission, snapshot);
324
+ const { page } = await readMessages(admission, selected, null, active);
325
+ // Descending window → take the newest `limit`, emit ascending; the
326
+ // newest observed sortKey seeds the enrollment feed watermark.
327
+ const ascending = page.messages.slice(0, limit).reverse();
328
+ const states: Record<string, CoordinateState> = { [coordinateKey(selected)]: Object.freeze({ mark: ascending.length ? ascending[ascending.length - 1]!.sortKey : null, floor: null, window: null, drained: null }) };
329
+ return Object.freeze({
330
+ identity: current,
331
+ messages: Object.freeze(ascending.map(observed => projectMessage(selected, observed))),
332
+ nextCursor: encodeCursor(current, [selected], states),
333
+ caughtUp: true, gap: false,
334
+ } satisfies AutomationProviderPage);
335
+ });
336
+ },
337
+ events(input, signal) {
338
+ const coordinates = automationArray(input.coordinates, 50).map(coordinate).sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
339
+ const limit = automationInteger(input.limit, 1, 500);
340
+ if (coordinates.length === 0 || new Set(coordinates.map(coordinateKey)).size !== coordinates.length) throw new Error("Distinct Beeper event scopes are required");
341
+ return run("events", signal, async (admission, active) => {
342
+ const snapshot = await accountsSnapshot(admission, active);
343
+ const current = snapshotIdentity(admission, snapshot);
344
+ const prior = parseCursor(input.cursor, current, coordinates);
345
+ const emitted: AutomationMessage[] = [];
346
+ const states: Record<string, CoordinateState> = { ...prior.states };
347
+ let caughtUp = true;
348
+ for (const selected of coordinates) {
349
+ if (emitted.length >= limit) { caughtUp = false; break; }
350
+ const key = coordinateKey(selected);
351
+ const stored = states[key];
352
+ const state = { mark: stored?.mark ?? null, floor: stored?.floor ?? null, window: stored?.window ?? null, drained: stored?.drained ?? null };
353
+ // A bounded number of window reads per coordinate per poll; a drain
354
+ // that outlasts it keeps its cursor and resumes next poll.
355
+ for (let scans = 0; scans < 32 && emitted.length < limit; scans += 1) {
356
+ const draining = state.floor !== null;
357
+ const { page } = await readMessages(admission, selected, draining ? state.window : null, active);
358
+ const lowerBound = draining ? (state.drained ?? state.floor) : state.mark;
359
+ const ascending = page.messages.filter(observed => lowerBound === null || observed.sortKey > lowerBound).reverse();
360
+ const take = ascending.slice(0, limit - emitted.length);
361
+ for (const observed of take) emitted.push(projectMessage(selected, observed));
362
+ const lastEmitted = take[take.length - 1]?.sortKey;
363
+ if (lastEmitted !== undefined) {
364
+ if (draining) state.drained = lastEmitted;
365
+ if (state.mark === null || lastEmitted > state.mark) state.mark = lastEmitted;
366
+ }
367
+ const exhausted = ascending.length === take.length;
368
+ const oldest = page.messages[page.messages.length - 1];
369
+ if (!draining) {
370
+ const previous = lowerBound;
371
+ if (previous !== null && page.messages.length !== 0 && ascending.length === page.messages.length && page.continuation !== null) {
372
+ // The whole newest window postdates the watermark: arrivals may
373
+ // sit below it. Drain deeper windows until one reaches `floor`.
374
+ state.floor = previous;
375
+ state.window = exhausted ? page.continuation : null;
376
+ state.drained = exhausted ? null : (lastEmitted ?? null);
377
+ continue;
378
+ }
379
+ if (!exhausted) caughtUp = false;
380
+ break;
381
+ }
382
+ if (!exhausted) break;
383
+ const floor = state.floor;
384
+ if ((oldest !== undefined && floor !== null && oldest.sortKey <= floor) || page.continuation === null) {
385
+ // The walk covered every surviving message above the watermark.
386
+ // Provider pruning below it deletes unobserved history, which is
387
+ // not a coverage gap.
388
+ state.floor = null; state.window = null; state.drained = null;
389
+ break;
390
+ }
391
+ state.window = page.continuation;
392
+ state.drained = null;
393
+ }
394
+ if (state.floor !== null) caughtUp = false;
395
+ states[key] = Object.freeze(state);
396
+ }
397
+ emitted.sort((a, b) => a.occurredAt === b.occurredAt ? a.id.localeCompare(b.id) : a.occurredAt.localeCompare(b.occurredAt));
398
+ return Object.freeze({
399
+ identity: current, messages: Object.freeze(emitted),
400
+ nextCursor: encodeCursor(current, coordinates, states), caughtUp, gap: false,
401
+ } satisfies AutomationProviderPage);
402
+ });
403
+ },
404
+ async send(input, signal): Promise<AutomationProviderSendResult> {
405
+ const selected = coordinate(input.coordinate), expected = parseAutomationIdentity(input.identity), action = parseAutomationAction(input.action);
406
+ automationText(input.intentId, 256);
407
+ let dispatched = false;
408
+ try {
409
+ return await run(action.kind, signal, async (admission, active) => {
410
+ const observe = async (holder: BeeperAutomationAdmission): Promise<AutomationIdentity> => snapshotIdentity(holder, await accountsSnapshot(holder, active));
411
+ try {
412
+ const current = await observe(admission);
413
+ if (sha(current) !== sha(expected) || action.kind !== "text" || !status(current, true).actions[action.kind].available) throw new Error("Beeper source identity or capability changed before dispatch");
414
+ const operationDeadline = new OperationDeadline(300_000, { signal: active });
415
+ try {
416
+ const acceptance = await executeBeeperDirectMessagingPart(
417
+ { account_id: selected.accountId, conversation_id: selected.conversationId, kind: "text", text: action.text, mentions: [], no_preview: false },
418
+ admission.auth,
419
+ {
420
+ operationDeadline, signal: active,
421
+ ...(options.execution.environment === undefined ? {} : { environment: options.execution.environment }),
422
+ ...(options.messagingDependencies === undefined ? {} : { dependencies: options.messagingDependencies }),
423
+ beforeExternalBegin: async () => {
424
+ const again = await options.authorize("text", active);
425
+ if (sha(again) !== sha(admission)) throw new Error("Beeper permission changed before writing the action");
426
+ if (sha(await observe(again)) !== sha(expected)) throw new Error("Beeper source identity changed before writing the action");
427
+ dispatched = true;
428
+ },
429
+ },
430
+ );
431
+ return Object.freeze({ state: "accepted", messageId: automationMessageId(acceptance.pendingMessageId), providerReceiptId: null, delivery: "unknown" } satisfies AutomationProviderSendResult);
432
+ } finally { operationDeadline.dispose(); }
433
+ } catch {
434
+ return Object.freeze(dispatched
435
+ ? { state: "indeterminate", reason: "The Beeper receipt or cleanup could not be verified; do not retry." }
436
+ : { state: "not-started", reason: "The selected Beeper account, permission, target, or action was unavailable." } satisfies AutomationProviderSendResult);
437
+ }
438
+ });
439
+ } catch {
440
+ return Object.freeze(dispatched
441
+ ? { state: "indeterminate", reason: "The Beeper outcome is uncertain and cannot be retried." }
442
+ : { state: "not-started", reason: "The selected Beeper account, permission, target, or action was unavailable." } satisfies AutomationProviderSendResult);
443
+ }
444
+ },
445
+ async close() { closed = true; lifetime.abort(); await inFlight?.catch(() => undefined); },
446
+ };
447
+ }
@@ -2544,11 +2544,11 @@ async function directMessagesV3(
2544
2544
  cursor: string;
2545
2545
  }> | null;
2546
2546
  }>> {
2547
- const selfUserId = requireBoundSelfAccount(
2547
+ const selfUserIds = boundSelfUserIds(
2548
+ requireBoundSelfAccount(realm.accounts, input.accountId, "messaging.read"),
2548
2549
  realm.accounts,
2549
- input.accountId,
2550
2550
  "messaging.read",
2551
- ).user.id;
2551
+ );
2552
2552
  const collected: unknown[] = [];
2553
2553
  const seenIds = new Set<string>();
2554
2554
  const seenCursors = new Set<string>();
@@ -2582,8 +2582,8 @@ async function directMessagesV3(
2582
2582
  input,
2583
2583
  ));
2584
2584
  if (parsed.some((message) =>
2585
- message.isSender === true && message.senderId !== selfUserId
2586
- || message.isSender === false && message.senderId === selfUserId)) {
2585
+ message.isSender === true && !selfUserIds.has(message.senderId)
2586
+ || message.isSender === false && selfUserIds.has(message.senderId))) {
2587
2587
  throw new Error(
2588
2588
  "Beeper Desktop direct message direction contradicted the bound account self identity",
2589
2589
  );
@@ -3868,7 +3868,7 @@ export async function reconcileBeeperLocalOperation(
3868
3868
  const actualState = action === "messaging.edit"
3869
3869
  ? message.isSender === true && message.text === value.text
3870
3870
  : message.reactions.some((reaction) =>
3871
- reaction.participantId === requireBoundAccount(accounts, accountId, action).user.id
3871
+ boundSelfUserIds(requireBoundAccount(accounts, accountId, action), accounts, action).has(reaction.participantId)
3872
3872
  && reaction.reactionKey === value.reaction);
3873
3873
  return Object.freeze({
3874
3874
  actualState,
@@ -4032,13 +4032,21 @@ function conversationOutput(
4032
4032
  if (accountId !== null && !accountIds.has(accountId)) {
4033
4033
  throw new Error("messaging.list requested an account outside the bound Beeper realm");
4034
4034
  }
4035
- const conversations = strictArray(raw, "Beeper conversations", MAX_CHATS)
4036
- .map((item, index) => parseConversation(
4037
- item,
4038
- `Beeper conversations[${index}]`,
4039
- accountIds,
4040
- accountId,
4041
- ));
4035
+ const rows = strictArray(raw, "Beeper conversations", MAX_CHATS);
4036
+ // Desktop keeps chats for stale bridge accounts that /v1/accounts no longer
4037
+ // enumerates. An unfiltered list excludes those rows instead of failing the
4038
+ // whole projection; every emitted row still binds to the realm and scoped
4039
+ // reads keep their exact-account check.
4040
+ let excludedOutOfRealm = 0;
4041
+ const conversations = rows.flatMap((item, index) => {
4042
+ if (
4043
+ accountId === null
4044
+ && typeof item === "object" && item !== null
4045
+ && typeof (item as Readonly<Record<string, unknown>>).accountID === "string"
4046
+ && !accountIds.has((item as Readonly<Record<string, unknown>>).accountID as string)
4047
+ ) { excludedOutOfRealm += 1; return []; }
4048
+ return [parseConversation(item, `Beeper conversations[${index}]`, accountIds, accountId)];
4049
+ });
4042
4050
  for (const conversation of conversations) {
4043
4051
  if (
4044
4052
  (input.archived !== null && (conversation.isArchived === true) !== input.archived)
@@ -4055,7 +4063,7 @@ function conversationOutput(
4055
4063
  }
4056
4064
  const ids = conversations.map((conversation) => `${conversation.accountId}\0${conversation.id}`);
4057
4065
  if (new Set(ids).size !== ids.length) throw new Error("Beeper conversations repeat an account-scoped ID");
4058
- const requestedLimitReached = conversations.length >= input.limit;
4066
+ const requestedLimitReached = rows.length >= input.limit;
4059
4067
  return Object.freeze({
4060
4068
  provider: "beeper",
4061
4069
  operation: "messaging.list",
@@ -4070,9 +4078,11 @@ function conversationOutput(
4070
4078
  remoteConversationSetComplete: false,
4071
4079
  continuationAvailable: false,
4072
4080
  requestedLimitReached,
4081
+ excludedOutOfRealmConversationCount: excludedOutOfRealm,
4073
4082
  warnings: Object.freeze([
4074
4083
  "beeper-cli-v0.6.2-chat-result-window-has-no-continuation",
4075
4084
  "newly-connected-accounts-may-have-incomplete-history",
4085
+ ...(excludedOutOfRealm > 0 ? ["beeper-out-of-realm-conversations-excluded"] : []),
4076
4086
  ]),
4077
4087
  }),
4078
4088
  });
@@ -4302,11 +4312,8 @@ function directMessageOutputV3(
4302
4312
  raw: unknown,
4303
4313
  continuation: Readonly<{ direction: "before" | "after"; cursor: string }> | null,
4304
4314
  ) {
4305
- const selfUserId = requireBoundSelfAccount(
4306
- accounts,
4307
- input.accountId,
4308
- "messaging.read",
4309
- ).user.id;
4315
+ const selfAccount = requireBoundSelfAccount(accounts, input.accountId, "messaging.read");
4316
+ const selfUserIds = boundSelfUserIds(selfAccount, accounts, "messaging.read");
4310
4317
  const messages = strictArray(raw, "Beeper direct messages", input.limit)
4311
4318
  .map((item, index) => parseMessage(
4312
4319
  item,
@@ -4315,7 +4322,7 @@ function directMessageOutputV3(
4315
4322
  ));
4316
4323
  if (messages.some((message) =>
4317
4324
  message.isSender !== null
4318
- && message.isSender !== (message.senderId === selfUserId))) {
4325
+ && message.isSender !== selfUserIds.has(message.senderId))) {
4319
4326
  throw new Error(
4320
4327
  "Beeper direct messages contradicted the bound account self identity",
4321
4328
  );
@@ -4367,7 +4374,8 @@ function directMessageOutputV3(
4367
4374
  projection: "bounded-local-desktop-direct-iterator",
4368
4375
  accountId: input.accountId,
4369
4376
  conversationId: input.conversationId,
4370
- selfUserId,
4377
+ selfUserId: selfAccount.user.id,
4378
+ canonicalSelfUserId: canonicalSelfUserId(accounts, "messaging.read"),
4371
4379
  requestCursor,
4372
4380
  requestDirection,
4373
4381
  requestedSender: input.sender,
@@ -4415,6 +4423,38 @@ function requireBoundSelfAccount(
4415
4423
  return account;
4416
4424
  }
4417
4425
 
4426
+ /** The realm's canonical Matrix self identity when exactly one is present;
4427
+ * Desktop reports it as `senderID`/`participantId` for the owner on every
4428
+ * bridge rather than each account's bridge-scoped user ID. */
4429
+ function canonicalSelfUserId(
4430
+ accounts: readonly BeeperAccountProjection[],
4431
+ operation: string,
4432
+ ): string | null {
4433
+ const canonical = accounts.filter((candidate) =>
4434
+ candidate.user.isSelf === true
4435
+ && (
4436
+ candidate.bridge.type.toLowerCase() === "matrix"
4437
+ || candidate.network?.toLowerCase() === "beeper"
4438
+ ));
4439
+ if (canonical.length > 1) {
4440
+ throw new Error(`${operation} found ambiguous canonical self identities`);
4441
+ }
4442
+ return canonical.length === 1 ? canonical[0]!.user.id : null;
4443
+ }
4444
+
4445
+ /** Self sender identities proven by the bound realm: the given bound account's
4446
+ * bridge-scoped user ID plus the canonical Matrix self identity. */
4447
+ function boundSelfUserIds(
4448
+ account: BeeperAccountProjection,
4449
+ accounts: readonly BeeperAccountProjection[],
4450
+ operation: string,
4451
+ ): ReadonlySet<string> {
4452
+ const self = new Set([account.user.id]);
4453
+ const canonical = canonicalSelfUserId(accounts, operation);
4454
+ if (canonical !== null) self.add(canonical);
4455
+ return self;
4456
+ }
4457
+
4418
4458
  function exactConversation(
4419
4459
  raw: unknown,
4420
4460
  accounts: readonly BeeperAccountProjection[],
@@ -284,7 +284,7 @@ const adapterOperations = beeperAdapterManifest.operations as Readonly<Record<
284
284
  >>;
285
285
 
286
286
  export const BEEPER_LOCAL_OPERATION_INPUT_TYPES = Object.freeze(Object.fromEntries(
287
- Object.keys(adapterOperations).sort().map((operation) => [
287
+ [...BEEPER_LOCAL_OPERATION_NAMES].sort().map((operation) => [
288
288
  operation,
289
289
  Object.freeze(Object.fromEntries(
290
290
  Object.keys(adapterOperations[operation]!.input.properties).sort().map((field) => [
@@ -2579,9 +2579,9 @@ export const BEEPER_CLI_V062_SURFACE_CONTRACT = defineLocalCliSurfaceContractV1(
2579
2579
  sdk: BEEPER_DESKTOP_API_PIN,
2580
2580
  runtime: {
2581
2581
  providerPluginId: "beeper-linked-device",
2582
- providerPluginVersion: "2.4.0",
2582
+ providerPluginVersion: "2.5.0",
2583
2583
  adapterId: "beeper-local",
2584
- adapterVersion: "2.4.0",
2584
+ adapterVersion: "2.5.0",
2585
2585
  operationContractVersions: BEEPER_LOCAL_OPERATION_CONTRACT_VERSIONS,
2586
2586
  operationInputTypes: BEEPER_LOCAL_OPERATION_INPUT_TYPES,
2587
2587
  target: BEEPER_DESKTOP_TARGET,
@@ -2600,7 +2600,7 @@ export const BEEPER_CLI_V062_CLASSIFICATION_SHA256 =
2600
2600
  export const BEEPER_CLI_V062_SEMANTIC_PROFILES_SHA256 =
2601
2601
  "fb7ea5f70f004dd8090c3e6e0996bfa00b0bab8ea5639203e2d1027602450ffe" as const;
2602
2602
  export const BEEPER_CLI_V062_WHOLE_SURFACE_SHA256 =
2603
- "72201ac5eb3532f7c159583f19009f547d7d313e86388466b57c135bd2dc4944" as const;
2603
+ "bedc3063a7a792686e351c78e7ba3ed2a5fc0cc6efd3d88a23d9b7ae777765e2" as const;
2604
2604
 
2605
2605
  export const BEEPER_CLI_V062_PUBLIC_MANUAL_SEMANTIC_PROFILE_SHA256 = Object.freeze({
2606
2606
  "setup": "cd432e2649e5724d70398e739a2d1c0c21557a23820aaa14562575a5fe689406",