@hraness/ghostget 0.18.12 → 0.18.14

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 (37) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +17 -8
  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-9ayntqrw.js → index-g6na3p6k.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 +12 -7
  15. package/skills/ghostget/SKILL.md +1 -1
  16. package/skills/ghostget/references/install.md +5 -5
  17. package/src/assets/adapters/beeper/wrench-web-adapter.json +37 -1
  18. package/src/assets/adapters/beeper/wrench-web-adapter.v2.4.0.json +1500 -0
  19. package/src/beeper-client-types.ts +1 -1
  20. package/src/control/menubar-cli.ts +570 -0
  21. package/src/control/menubar-icon.ts +4 -0
  22. package/src/messaging-automation-descriptors.ts +9 -1
  23. package/src/messaging-automation-factory.ts +7 -5
  24. package/src/messaging-automation-server.ts +2 -2
  25. package/src/messaging-automation-types.ts +3 -2
  26. package/src/messaging-automation-validation.ts +5 -1
  27. package/src/plugins/beeper-linked-device/plugin.ts +3 -1
  28. package/src/provider-plugin-contract-identity.ts +11 -2
  29. package/src/providers/beeper-automation.ts +447 -0
  30. package/src/providers/beeper-local-runtime.ts +61 -21
  31. package/src/providers/beeper-local.ts +4 -4
  32. package/src/providers/beeper-omni.ts +9 -2
  33. package/src/providers/imessage-direct-runtime.ts +28 -2
  34. package/src/providers/messaging-native-install.ts +2 -1
  35. package/src/support-profile.ts +7 -0
  36. package/src/support.ts +1 -7
  37. package/src/version.ts +1 -1
@@ -4,7 +4,7 @@ import type { GhostgetAuth } from "./auth";
4
4
  import { MessagingAutomationHost } from "./messaging-automation";
5
5
  import type { AutomationActionKind, AutomationProviderId, AutomationProviderStatus, MessagingAutomationProvider } from "./messaging-automation-types";
6
6
  import { AUTOMATION_ACTION_KINDS } from "./messaging-automation-validation";
7
- import { automationPermissionOperation, loadImsgAutomationRuntime, loadWhatsAppAutomationRuntime } from "./messaging-automation-descriptors";
7
+ import { automationPermissionOperation, loadBeeperAutomationRuntime, loadImsgAutomationRuntime, loadWhatsAppAutomationRuntime } from "./messaging-automation-descriptors";
8
8
  import { describeOperationPermission, type OperationPermissionDescription } from "./operation-permission";
9
9
  import { loadProviderPluginExtensionRuntime } from "./provider-plugin";
10
10
  import type { ProviderPluginRegistry } from "./provider-plugin-registry";
@@ -29,7 +29,7 @@ export async function createMessagingAutomationSession(options: MessagingAutomat
29
29
  const starts = new Map<AutomationProviderId, (signal?: AbortSignal) => Promise<AutomationProviderStatus>>();
30
30
  try {
31
31
  for (const selected of options.providers) {
32
- const adapterId = selected.provider === "imessage" ? "imessage-direct" : "whatsapp-web";
32
+ const adapterId = selected.provider === "imessage" ? "imessage-direct" : selected.provider === "beeper" ? "beeper-local" : "whatsapp-web";
33
33
  let custody: WebSessionCleanupAdmissionController | undefined;
34
34
  let custodyIdentity: string | undefined;
35
35
  let poisoned = false, persistent = false, closed = false, started = false;
@@ -59,7 +59,7 @@ export async function createMessagingAutomationSession(options: MessagingAutomat
59
59
  pluginImplementationHash: options.registry.implementationHash(description.resolution.binding).toString("hex"),
60
60
  adapterId, adapterHash: description.coordinate.manifestHash, surfaceId: selected.provider,
61
61
  authId: selected.authId, authHash: sha256(canonicalJson(auth)),
62
- transport: selected.provider === "imessage" ? "local-cli" : "web-session-api",
62
+ transport: selected.provider === "whatsapp" ? "web-session-api" : "local-cli",
63
63
  executionIdentityHash: implementationIdentity,
64
64
  }, options.environment);
65
65
  custodyIdentity = coordinate;
@@ -85,10 +85,12 @@ export async function createMessagingAutomationSession(options: MessagingAutomat
85
85
  return finishing;
86
86
  };
87
87
  const execution = { environment: options.environment, registerCleanupBarrier };
88
- const binding = options.registry.requireOperationDefinition(selected.provider === "imessage" ? "local-cli" : "linked-device", selected.provider, "messaging.automation.read", 1).binding;
88
+ const binding = options.registry.requireOperationDefinition(selected.provider === "whatsapp" ? "linked-device" : "local-cli", selected.provider, "messaging.automation.read", 1).binding;
89
89
  const concrete: Concrete = await loadProviderPluginExtensionRuntime(binding.loadRuntime, async () => selected.provider === "imessage"
90
90
  ? (await loadImsgAutomationRuntime()).createImsgAutomationProvider({ authorize, execution, resolveAsset: options.resolveAsset })
91
- : (await loadWhatsAppAutomationRuntime()).createWhatsAppAutomationProvider({ authorize, execution, resolveAsset: options.resolveAsset }));
91
+ : selected.provider === "beeper"
92
+ ? (await loadBeeperAutomationRuntime()).createBeeperAutomationProvider({ authorize, execution })
93
+ : (await loadWhatsAppAutomationRuntime()).createWhatsAppAutomationProvider({ authorize, execution, resolveAsset: options.resolveAsset }));
92
94
  const call = async <T>(work: () => Promise<T>): Promise<T> => {
93
95
  if (poisoned) throw new AutomationHostRecoveryRequired("Host is fenced");
94
96
  if (closed) throw new Error("Host closed");
@@ -15,7 +15,7 @@ type Asset = { bytes: Uint8Array; sha256: string; expires: number; plan: string
15
15
  const MAX_ASSET = 16 * 1024 * 1024;
16
16
  const MAX_FRAME = 24 * 1024 * 1024;
17
17
  const MAX_RESPONSE = 32 * 1024 * 1024;
18
- const provider = (value: unknown): AutomationProviderId => { if (value !== "imessage" && value !== "whatsapp") throw new Error("Invalid provider"); return value; };
18
+ const provider = (value: unknown): AutomationProviderId => { if (value !== "imessage" && value !== "whatsapp" && value !== "beeper") throw new Error("Invalid provider"); return value; };
19
19
  export class AutomationHostRecoveryRequired extends Error {}
20
20
 
21
21
  /** One trusted owner connection. No agent receives this port. */
@@ -44,7 +44,7 @@ export class MessagingAutomationRpcServer {
44
44
  if (method === "initialize") {
45
45
  if (this.initialized || this.closed) throw new Error("Already initialized");
46
46
  const r = automationRecord(raw, ["providers"]);
47
- const accounts = automationArray(r.providers, 2).map(value => {
47
+ const accounts = automationArray(r.providers, 3).map(value => {
48
48
  const item = automationRecord(value, ["provider", "authId"]); const authId = automationText(item.authId, 48);
49
49
  if (!/^[a-z][a-z0-9-]{0,47}$/u.test(authId)) throw new Error("Invalid account identifier");
50
50
  return { provider: provider(item.provider), authId };
@@ -1,11 +1,12 @@
1
1
  /** Closed host-side messaging contract. Provider credentials and local paths never cross it. */
2
2
  export const MESSAGING_AUTOMATION_PROTOCOL = "ghostget.messaging-automation/1" as const;
3
3
 
4
- export type AutomationProviderId = "imessage" | "whatsapp";
4
+ export type AutomationProviderId = "imessage" | "whatsapp" | "beeper";
5
5
  export type AutomationActionKind = AutomationAction["kind"];
6
6
  export type AutomationCoordinate =
7
7
  | Readonly<{ provider: "imessage"; chatGuid: string; service: "iMessage"; observedChatRowId: number }>
8
- | Readonly<{ provider: "whatsapp"; conversationJid: string }>;
8
+ | Readonly<{ provider: "whatsapp"; conversationJid: string }>
9
+ | Readonly<{ provider: "beeper"; accountId: string; conversationId: string }>;
9
10
  export type AutomationIdentity = Readonly<{
10
11
  provider: AutomationProviderId;
11
12
  authId: string;
@@ -53,13 +53,17 @@ export function parseAutomationCoordinate(value: unknown): AutomationCoordinate
53
53
  if (!chatGuid.startsWith("iMessage;") || r.service !== "iMessage") throw new Error("Only exact iMessage conversations are supported.");
54
54
  return Object.freeze({ provider, chatGuid, service: "iMessage", observedChatRowId: automationInteger(r.observedChatRowId, 1, Number.MAX_SAFE_INTEGER) });
55
55
  }
56
+ if (provider === "beeper") {
57
+ const r = automationRecord(value, ["provider", "accountId", "conversationId"]);
58
+ return Object.freeze({ provider, accountId: automationText(r.accountId, 512), conversationId: automationText(r.conversationId, 2048) });
59
+ }
56
60
  const r = automationRecord(value, ["provider", "conversationJid"]);
57
61
  if (provider !== "whatsapp" || typeof r.conversationJid !== "string" || !/^(?:[1-9][0-9]{4,14}@s\.whatsapp\.net|[1-9][0-9]{4,19}@lid)$/u.test(r.conversationJid)) throw new Error("Only exact individual WhatsApp conversations are supported.");
58
62
  return Object.freeze({ provider, conversationJid: r.conversationJid });
59
63
  }
60
64
  export function parseAutomationIdentity(value: unknown): AutomationIdentity {
61
65
  const r = automationRecord(value, ["provider", "authId", "accountIdentity", "accountSubject", "implementationIdentity", "sourceGeneration"]);
62
- if (r.provider !== "imessage" && r.provider !== "whatsapp") throw new Error("Messaging provider is invalid.");
66
+ if (r.provider !== "imessage" && r.provider !== "whatsapp" && r.provider !== "beeper") throw new Error("Messaging provider is invalid.");
63
67
  return Object.freeze({ provider: r.provider, authId: automationId(r.authId), accountIdentity: automationDigest(r.accountIdentity), accountSubject: automationText(r.accountSubject, 512), implementationIdentity: automationDigest(r.implementationIdentity), sourceGeneration: automationText(r.sourceGeneration, 256) });
64
68
  }
65
69
  export function parseAutomationActionKind(value: unknown): AutomationActionKind {
@@ -1,3 +1,4 @@
1
+ import { automationOperationDefinitions } from "../../messaging-automation-descriptors";
1
2
  import {
2
3
  defineProviderPlugin,
3
4
  lazyLocalCliRuntime,
@@ -250,12 +251,13 @@ const operations = Object.freeze([
250
251
  operationDefinition(action, 2)),
251
252
  operationDefinition("messaging.read", 3),
252
253
  operationDefinition("contacts.list", 3),
254
+ ...automationOperationDefinitions("beeper"),
253
255
  ]);
254
256
 
255
257
  export const beeperLinkedDevicePlugin = defineProviderPlugin({
256
258
  apiVersion: 1,
257
259
  id: "beeper-linked-device",
258
- version: "2.4.0",
260
+ version: "2.5.0",
259
261
  displayName: "Beeper Pinned Local CLI",
260
262
  sourceKind: "built-in",
261
263
  implementationSources: providerImplementationEntry(import.meta.url),
@@ -69,6 +69,10 @@ const BEEPER_2_3_BINDING_ROUTE_COORDINATES = Object.freeze([
69
69
  "local-cli:beeper/contacts.list@2",
70
70
  "local-cli:beeper/messaging.read@3",
71
71
  ]);
72
+ const BEEPER_2_4_BINDING_ROUTE_COORDINATES = Object.freeze([
73
+ ...BEEPER_2_3_BINDING_ROUTE_COORDINATES,
74
+ "local-cli:beeper/contacts.list@3",
75
+ ]);
72
76
 
73
77
  const REDDIT_1_3_BINDING_ROUTE_COORDINATES = Object.freeze([
74
78
  "comments.create@1", "comments.read@1", "communities.membership.set@1",
@@ -84,10 +88,15 @@ const REDDIT_1_3_BINDING_ROUTE_COORDINATES = Object.freeze([
84
88
  const identities = Object.freeze({
85
89
  "beeper-linked-device": {
86
90
  schemaVersion: 1,
87
- pluginVersion: "2.4.0",
88
- implementationSha256: "a989e65e372aa63af41cb36f32d6a1d769c734ba0adc207dd2f25d916f877ff5",
91
+ pluginVersion: "2.5.0",
92
+ implementationSha256: "112abd8d9a819c03d56ac0d8d7e4b5178cde5b83b1cf41144e38c4ce42bc3945",
89
93
  legacyCurrentReadImplementationSha256: [],
90
94
  legacyDistributionReadImplementationSha256: [
95
+ {
96
+ implementationSha256:
97
+ "a989e65e372aa63af41cb36f32d6a1d769c734ba0adc207dd2f25d916f877ff5",
98
+ routes: [...BEEPER_2_4_BINDING_ROUTE_COORDINATES],
99
+ },
91
100
  {
92
101
  implementationSha256:
93
102
  "2d2cef38ce2d0c193f4e6890c51d59f8a3547d9011d5c3aa8df18f5f077ebcdd",
@@ -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
+ }