@frockbot/plugin-machine-messages 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/device.ts ADDED
@@ -0,0 +1,413 @@
1
+ // What one Messages call becomes on the Mac — everything but the OS calls.
2
+ //
3
+ // This is row 57g's half of "the plan's honest floor": the SQL that reads
4
+ // `chat.db`, the Apple-epoch arithmetic, the row shapes, the AppleScript a send
5
+ // composes, and every classification decision — which outcome a missing
6
+ // attachment gets, what a denied permission answers, what `truncated` means —
7
+ // all live here, in a Package, under `bun test`. What is left for
8
+ // `apps/desktop/src/main` is three verbs it cannot avoid being Node for:
9
+ // opening a SQLite file read-only, running `osascript`, and reading bytes off a
10
+ // disk.
11
+ //
12
+ // Two rules this file exists to hold:
13
+ //
14
+ // 1. **The database is never written and never asked for anything but rows.**
15
+ // The seam takes a statement and parameters, and every statement here is a
16
+ // `SELECT`. A Bot's text reaches SQLite only as a bound parameter, so a
17
+ // message containing a quote is a message and not a query.
18
+ // 2. **Permissions are checked on the machine, every call.** The backend
19
+ // refuses on the last *report*; this refuses on what is true right now. The
20
+ // two are not redundant: TCC consent can be withdrawn between a Turn and
21
+ // the poll that carries its command.
22
+ import {
23
+ MACHINE_LIMITS_V1,
24
+ MACHINE_MESSAGES_LIMITS_V1,
25
+ type MachineMessagesCallV1,
26
+ type MachineMessagesPermissionsV1,
27
+ } from "@frockbot/machine-protocol";
28
+ import type { MachineCommandReportV1 } from "@frockbot/plugin-user-machine/device";
29
+ import type { MachineMessagesOpRunnerV1 } from "@frockbot/plugin-user-machine/device-runner";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // The seam
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /** One row of a `chat.db` read, as SQLite hands it back. */
36
+ export type MachineMessagesRowV1 = Record<string, string | number | null>;
37
+
38
+ export interface MachineMessagesQueryRequestV1 {
39
+ sql: string;
40
+ parameters: Array<string | number>;
41
+ maxRows: number;
42
+ }
43
+
44
+ /**
45
+ * The only authority a Messages call has over the Mac.
46
+ *
47
+ * Four verbs, and none of them takes a decision. `apps/desktop` implements it
48
+ * with `node:sqlite`, `osascript` and `node:fs`; a plain object implements it
49
+ * in a test, which is why every line above it runs in CI.
50
+ */
51
+ export interface MachineMessagesDeviceSeamV1 {
52
+ /** Whether macOS has granted Full Disk Access and Automation, right now. */
53
+ checkPermissions(
54
+ signal: AbortSignal,
55
+ ): Promise<Omit<MachineMessagesPermissionsV1, "schemaVersion" | "checkedAt">>;
56
+ /** One read-only `SELECT` against `~/Library/Messages/chat.db`. */
57
+ query(
58
+ request: MachineMessagesQueryRequestV1,
59
+ signal: AbortSignal,
60
+ ): Promise<MachineMessagesRowV1[]>;
61
+ /** Tell Messages.app to send. The AppleScript is composed here, not there. */
62
+ send(
63
+ request: { recipient: string; text: string },
64
+ signal: AbortSignal,
65
+ ): Promise<void>;
66
+ /** One attachment's bytes, bounded. */
67
+ readFile(
68
+ request: { path: string; maxBytes: number },
69
+ signal: AbortSignal,
70
+ ): Promise<{ bytesBase64: string; truncated: boolean }>;
71
+ /** The user's home directory, so a `~`-relative attachment path resolves. */
72
+ home(): string;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // chat.db
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /** Where the Messages database lives, relative to a home directory. */
80
+ export function machineMessagesDatabasePathV1(home: string): string {
81
+ return `${home.replace(/\/$/, "")}/Library/Messages/chat.db`;
82
+ }
83
+
84
+ /**
85
+ * Apple's epoch is 2001-01-01, and `message.date` has been nanoseconds since
86
+ * it for a decade — but rows written by very old macOS versions are seconds.
87
+ * The magnitude tells them apart, which is cheaper and more honest than
88
+ * guessing from an OS version the backend cannot see.
89
+ */
90
+ export const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
91
+
92
+ export function appleDateToIsoV1(value: unknown): string | undefined {
93
+ if (typeof value !== "number" || !Number.isFinite(value) || value === 0) {
94
+ return undefined;
95
+ }
96
+ const seconds = Math.abs(value) > 1e11 ? value / 1_000_000_000 : value;
97
+ const at = (seconds + APPLE_EPOCH_OFFSET_SECONDS) * 1_000;
98
+ if (!Number.isFinite(at) || Math.abs(at) > 8.64e15) return undefined;
99
+ return new Date(at).toISOString();
100
+ }
101
+
102
+ function text(value: unknown): string | undefined {
103
+ return typeof value === "string" && value.length > 0 ? value : undefined;
104
+ }
105
+
106
+ /** One conversation, as a tool result renders it. */
107
+ export interface MachineMessagesChatV1 {
108
+ chatId: string;
109
+ name?: string;
110
+ handle?: string;
111
+ lastMessageAt?: string;
112
+ }
113
+
114
+ export function machineMessagesChatRowV1(
115
+ row: MachineMessagesRowV1,
116
+ ): MachineMessagesChatV1 {
117
+ return {
118
+ chatId: String(row.guid ?? row.chat_identifier ?? row.ROWID ?? ""),
119
+ ...(text(row.display_name) === undefined
120
+ ? {}
121
+ : { name: text(row.display_name)! }),
122
+ ...(text(row.chat_identifier) === undefined
123
+ ? {}
124
+ : { handle: text(row.chat_identifier)! }),
125
+ ...(appleDateToIsoV1(row.last_date) === undefined
126
+ ? {}
127
+ : { lastMessageAt: appleDateToIsoV1(row.last_date)! }),
128
+ };
129
+ }
130
+
131
+ /** One message, as a tool result renders it. */
132
+ export interface MachineMessagesItemV1 {
133
+ rowId: number;
134
+ chatId?: string;
135
+ fromMe: boolean;
136
+ handle?: string;
137
+ text?: string;
138
+ at?: string;
139
+ attachmentId?: string;
140
+ }
141
+
142
+ export function machineMessagesItemRowV1(
143
+ row: MachineMessagesRowV1,
144
+ ): MachineMessagesItemV1 {
145
+ const rowId = typeof row.ROWID === "number" ? row.ROWID : 0;
146
+ return {
147
+ rowId,
148
+ ...(text(row.chat_guid) === undefined
149
+ ? {}
150
+ : { chatId: text(row.chat_guid)! }),
151
+ fromMe: row.is_from_me === 1,
152
+ ...(text(row.handle) === undefined ? {} : { handle: text(row.handle)! }),
153
+ ...(text(row.text) === undefined ? {} : { text: text(row.text)! }),
154
+ ...(appleDateToIsoV1(row.date) === undefined
155
+ ? {}
156
+ : { at: appleDateToIsoV1(row.date)! }),
157
+ ...(row.attachment_id === null || row.attachment_id === undefined
158
+ ? {}
159
+ : { attachmentId: String(row.attachment_id) }),
160
+ };
161
+ }
162
+
163
+ const CHAT_COLUMNS =
164
+ "c.ROWID AS ROWID, c.guid AS guid, c.chat_identifier AS chat_identifier, c.display_name AS display_name, MAX(m.date) AS last_date";
165
+
166
+ const MESSAGE_COLUMNS =
167
+ "m.ROWID AS ROWID, m.text AS text, m.is_from_me AS is_from_me, m.date AS date, h.id AS handle, c.guid AS chat_guid, (SELECT attachment_id FROM message_attachment_join WHERE message_id = m.ROWID LIMIT 1) AS attachment_id";
168
+
169
+ /**
170
+ * The statement one call runs, and its bound parameters.
171
+ *
172
+ * Exported because it is the interesting half: a test asserts the exact SQL and
173
+ * the exact parameters, which is how "a Bot's text never becomes a query" is
174
+ * checked rather than asserted.
175
+ */
176
+ export function machineMessagesQueryV1(
177
+ call: MachineMessagesCallV1,
178
+ ): MachineMessagesQueryRequestV1 {
179
+ if (call.kind === "find-chats") {
180
+ const filter = call.query
181
+ ? " WHERE c.display_name LIKE ?1 OR c.chat_identifier LIKE ?1"
182
+ : "";
183
+ return {
184
+ sql: `SELECT ${CHAT_COLUMNS} FROM chat c JOIN chat_message_join j ON j.chat_id = c.ROWID JOIN message m ON m.ROWID = j.message_id${filter} GROUP BY c.ROWID ORDER BY last_date DESC LIMIT ${call.limit}`,
185
+ parameters: call.query ? [`%${call.query}%`] : [],
186
+ maxRows: call.limit,
187
+ };
188
+ }
189
+ if (call.kind === "chat-items") {
190
+ const paging = call.beforeRowId === undefined ? "" : " AND m.ROWID < ?2";
191
+ return {
192
+ sql: `SELECT ${MESSAGE_COLUMNS} FROM message m JOIN chat_message_join j ON j.message_id = m.ROWID JOIN chat c ON c.ROWID = j.chat_id LEFT JOIN handle h ON h.ROWID = m.handle_id WHERE (c.guid = ?1 OR c.chat_identifier = ?1)${paging} ORDER BY m.date DESC LIMIT ${call.limit}`,
193
+ parameters:
194
+ call.beforeRowId === undefined
195
+ ? [call.chatId]
196
+ : [call.chatId, call.beforeRowId],
197
+ maxRows: call.limit,
198
+ };
199
+ }
200
+ if (call.kind === "search") {
201
+ return {
202
+ sql: `SELECT ${MESSAGE_COLUMNS} FROM message m JOIN chat_message_join j ON j.message_id = m.ROWID JOIN chat c ON c.ROWID = j.chat_id LEFT JOIN handle h ON h.ROWID = m.handle_id WHERE m.text LIKE ?1 ORDER BY m.date DESC LIMIT ${call.limit}`,
203
+ parameters: [`%${call.query}%`],
204
+ maxRows: call.limit,
205
+ };
206
+ }
207
+ if (call.kind === "activity") {
208
+ return {
209
+ sql: `SELECT ${MESSAGE_COLUMNS} FROM message m JOIN chat_message_join j ON j.message_id = m.ROWID JOIN chat c ON c.ROWID = j.chat_id LEFT JOIN handle h ON h.ROWID = m.handle_id ORDER BY m.date DESC LIMIT ${call.limit}`,
210
+ parameters: [],
211
+ maxRows: call.limit,
212
+ };
213
+ }
214
+ if (call.kind === "fetch-attachment") {
215
+ return {
216
+ sql: "SELECT a.ROWID AS ROWID, a.guid AS guid, a.filename AS filename, a.mime_type AS mime_type, a.total_bytes AS total_bytes FROM attachment a WHERE CAST(a.ROWID AS TEXT) = ?1 OR a.guid = ?1 LIMIT 1",
217
+ parameters: [call.attachmentId],
218
+ maxRows: 1,
219
+ };
220
+ }
221
+ throw new Error(`${call.kind} is not a chat.db read`);
222
+ }
223
+
224
+ /** `~/…` as `chat.db` stores it, resolved against the agent's own home. */
225
+ export function machineMessagesAttachmentPathV1(
226
+ filename: string,
227
+ home: string,
228
+ ): string {
229
+ if (filename.startsWith("~/")) {
230
+ return `${home.replace(/\/$/, "")}/${filename.slice(2)}`;
231
+ }
232
+ return filename;
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Sending
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * AppleScript has no parameter binding, so the escape is the whole safety
241
+ * story: a double quote or a backslash in somebody's message must not be able
242
+ * to close the string and become script. Everything else — including a newline,
243
+ * which AppleScript string literals accept — is left exactly as the user wrote
244
+ * it, because a send that silently rewrote the message would be worse than one
245
+ * that refused.
246
+ */
247
+ export function escapeAppleScriptStringV1(value: string): string {
248
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
249
+ }
250
+
251
+ /** The script one send runs. Composed here so its escaping is tested here. */
252
+ export function machineMessagesSendScriptV1(
253
+ recipient: string,
254
+ body: string,
255
+ ): string {
256
+ return [
257
+ 'tell application "Messages"',
258
+ " set targetService to 1st account whose service type = iMessage",
259
+ ` set targetBuddy to participant "${escapeAppleScriptStringV1(recipient)}" of targetService`,
260
+ ` send "${escapeAppleScriptStringV1(body)}" to targetBuddy`,
261
+ "end tell",
262
+ ].join("\n");
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // The runner
267
+ // ---------------------------------------------------------------------------
268
+
269
+ export interface MachineMessagesDeviceRunnerOptionsV1 {
270
+ seam: MachineMessagesDeviceSeamV1;
271
+ now?(): number;
272
+ }
273
+
274
+ /**
275
+ * The refusal wording, once. "Refused:" is load-bearing — `plugin-audit`'s
276
+ * `outcomeFor` classifies on that prefix, so a permission the user never
277
+ * granted reads as a decision rather than a failure of their Mac.
278
+ */
279
+ export function machineMessagesRefusalV1(reason: string): string {
280
+ return `Refused: ${reason}`;
281
+ }
282
+
283
+ export const MACHINE_MESSAGES_FULL_DISK_REFUSAL_V1 =
284
+ "macOS has not granted FrockBot Full Disk Access on this Mac, so Messages history cannot be read. Grant it in System Settings › Privacy & Security › Full Disk Access and restart FrockBot.";
285
+
286
+ export const MACHINE_MESSAGES_AUTOMATION_REFUSAL_V1 =
287
+ "macOS has not granted FrockBot Automation rights over Messages.app on this Mac, so nothing can be sent. Grant it in System Settings › Privacy & Security › Automation.";
288
+
289
+ /**
290
+ * One Messages call, run.
291
+ *
292
+ * Never throws: a thrown handler would leave a claimed command unanswered until
293
+ * its lease lapsed, and an `error` outcome a Bot can read is strictly better
294
+ * than silence.
295
+ */
296
+ export function createMachineMessagesDeviceRunnerV1(
297
+ options: MachineMessagesDeviceRunnerOptionsV1,
298
+ ): MachineMessagesOpRunnerV1 {
299
+ const at = (): string =>
300
+ new Date(options.now?.() ?? Date.now()).toISOString();
301
+ const refuse = (reason: string): MachineCommandReportV1 => ({
302
+ finishedAt: at(),
303
+ outcome: "refused",
304
+ truncated: false,
305
+ message: machineMessagesRefusalV1(reason).slice(
306
+ 0,
307
+ MACHINE_LIMITS_V1.message,
308
+ ),
309
+ });
310
+ const ok = (
311
+ body: Record<string, unknown>,
312
+ extra: Partial<MachineCommandReportV1> = {},
313
+ ): MachineCommandReportV1 => ({
314
+ finishedAt: at(),
315
+ outcome: "ok",
316
+ truncated: false,
317
+ stdout: JSON.stringify(body),
318
+ ...extra,
319
+ });
320
+
321
+ return async (call, signal) => {
322
+ try {
323
+ const observed = await options.seam.checkPermissions(signal);
324
+ const permissions: MachineMessagesPermissionsV1 = {
325
+ schemaVersion: 1,
326
+ fullDiskAccess: observed.fullDiskAccess,
327
+ automation: observed.automation,
328
+ checkedAt: at(),
329
+ ...(observed.detail === undefined
330
+ ? {}
331
+ : {
332
+ detail: observed.detail.slice(
333
+ 0,
334
+ MACHINE_MESSAGES_LIMITS_V1.detail,
335
+ ),
336
+ }),
337
+ };
338
+ // The check itself always answers. It is how a machine stops being
339
+ // unknown to the backend, so refusing it for want of a permission would
340
+ // be a gate that can never be opened.
341
+ if (call.kind === "check-permissions") {
342
+ return ok({ kind: "permissions", permissions });
343
+ }
344
+ if (!permissions.fullDiskAccess) {
345
+ return refuse(MACHINE_MESSAGES_FULL_DISK_REFUSAL_V1);
346
+ }
347
+ if (call.kind === "send") {
348
+ if (!permissions.automation) {
349
+ return refuse(MACHINE_MESSAGES_AUTOMATION_REFUSAL_V1);
350
+ }
351
+ await options.seam.send(
352
+ { recipient: call.to, text: call.text },
353
+ signal,
354
+ );
355
+ return ok({ kind: "sent", to: call.to, at: at() });
356
+ }
357
+ const rows = await options.seam.query(
358
+ machineMessagesQueryV1(call),
359
+ signal,
360
+ );
361
+ if (call.kind === "find-chats") {
362
+ return ok({
363
+ kind: "chats",
364
+ chats: rows.map((row) => machineMessagesChatRowV1(row)),
365
+ });
366
+ }
367
+ if (call.kind === "fetch-attachment") {
368
+ const row = rows[0];
369
+ const filename = row === undefined ? undefined : text(row.filename);
370
+ if (!filename) {
371
+ return refuse(
372
+ `no attachment "${call.attachmentId}" is in this Mac's Messages database`,
373
+ );
374
+ }
375
+ const file = await options.seam.readFile(
376
+ {
377
+ path: machineMessagesAttachmentPathV1(
378
+ filename,
379
+ options.seam.home(),
380
+ ),
381
+ maxBytes: call.maxBytes,
382
+ },
383
+ signal,
384
+ );
385
+ return ok(
386
+ {
387
+ kind: "attachment",
388
+ attachmentId: call.attachmentId,
389
+ ...(text(row?.mime_type) === undefined
390
+ ? {}
391
+ : { mimeType: text(row?.mime_type)! }),
392
+ truncated: file.truncated,
393
+ },
394
+ { truncated: file.truncated, bytesBase64: file.bytesBase64 },
395
+ );
396
+ }
397
+ return ok({
398
+ kind: "items",
399
+ items: rows.map((row) => machineMessagesItemRowV1(row)),
400
+ });
401
+ } catch (error) {
402
+ return {
403
+ finishedAt: at(),
404
+ outcome: "error",
405
+ truncated: false,
406
+ message: (error instanceof Error ? error.message : String(error)).slice(
407
+ 0,
408
+ MACHINE_LIMITS_V1.message,
409
+ ),
410
+ };
411
+ }
412
+ };
413
+ }
@@ -0,0 +1,99 @@
1
+ // The two gates that decide whether the Messages tools exist at all.
2
+ import { describe, expect, test } from "bun:test";
3
+ import type { MachineListEntryV1 } from "@frockbot/machine-protocol";
4
+ import {
5
+ MACHINE_MESSAGES_SETTING_V1,
6
+ machineMessagesEnabledV1,
7
+ machineMessagesGateV1,
8
+ } from "./gate.js";
9
+
10
+ const NOW = "2026-09-01T00:00:00.000Z";
11
+
12
+ function entry(
13
+ overrides: Partial<MachineListEntryV1> = {},
14
+ ): MachineListEntryV1 {
15
+ return {
16
+ machineId: "mac-1",
17
+ label: "Tims-M5-MacBook-Pro.local",
18
+ platform: "macos",
19
+ capabilities: ["exec", "files", "messages"],
20
+ connected: true,
21
+ lastSeenAt: NOW,
22
+ registeredAt: NOW,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ describe("the feature gate", () => {
28
+ test("a setting nobody has touched is off", () => {
29
+ expect(machineMessagesEnabledV1(undefined)).toBe(false);
30
+ expect(machineMessagesEnabledV1({})).toBe(false);
31
+ });
32
+
33
+ test("only true is on", () => {
34
+ expect(
35
+ machineMessagesEnabledV1({ [MACHINE_MESSAGES_SETTING_V1]: true }),
36
+ ).toBe(true);
37
+ for (const value of ["true", 1, "", 0, false] as const) {
38
+ expect(
39
+ machineMessagesEnabledV1({ [MACHINE_MESSAGES_SETTING_V1]: value }),
40
+ ).toBe(false);
41
+ }
42
+ });
43
+
44
+ test("off is off whatever the registry holds", () => {
45
+ expect(
46
+ machineMessagesGateV1({ enabled: false, machines: [entry()] }),
47
+ ).toEqual({ status: "off" });
48
+ });
49
+ });
50
+
51
+ describe("the capability gate", () => {
52
+ test("a connected macOS machine reporting messages opens it", () => {
53
+ expect(
54
+ machineMessagesGateV1({ enabled: true, machines: [entry()] }),
55
+ ).toEqual({ status: "ready", machineIds: ["mac-1"] });
56
+ });
57
+
58
+ test("every way a machine fails to qualify", () => {
59
+ const refused: Array<Partial<MachineListEntryV1>> = [
60
+ // Never reported the capability — the agent had no handlers behind it.
61
+ { capabilities: ["exec", "files"] },
62
+ // Not a Mac. The protocol refuses the claim at enrollment; this refuses
63
+ // it again, because a registry row can outlive the build that wrote it.
64
+ { platform: "linux", capabilities: ["exec", "files"] },
65
+ // The laptop is asleep. A command has nowhere to go.
66
+ { connected: false },
67
+ // Revoked. The row is evidence, not a machine.
68
+ { revokedAt: NOW },
69
+ ];
70
+ for (const overrides of refused) {
71
+ expect(
72
+ machineMessagesGateV1({
73
+ enabled: true,
74
+ machines: [entry(overrides)],
75
+ }),
76
+ ).toEqual({ status: "no-machine" });
77
+ }
78
+ expect(machineMessagesGateV1({ enabled: true, machines: [] })).toEqual({
79
+ status: "no-machine",
80
+ });
81
+ });
82
+
83
+ test("one qualifying machine among several is enough, and only it is named", () => {
84
+ expect(
85
+ machineMessagesGateV1({
86
+ enabled: true,
87
+ machines: [
88
+ entry({
89
+ machineId: "linux-1",
90
+ platform: "linux",
91
+ capabilities: ["exec"],
92
+ }),
93
+ entry({ machineId: "mac-asleep", connected: false }),
94
+ entry({ machineId: "mac-2" }),
95
+ ],
96
+ }),
97
+ ).toEqual({ status: "ready", machineIds: ["mac-2"] });
98
+ });
99
+ });
package/src/gate.ts ADDED
@@ -0,0 +1,79 @@
1
+ // Row 57g's gate, as one pure function.
2
+ //
3
+ // The register gates the Messages tools twice — "behind a feature gate and a
4
+ // permission check" — and the transport adds a third that falls out of what a
5
+ // laptop is: the tools mean nothing without a connected Mac to run them on. So
6
+ // there are three independent answers, and they are deliberately different
7
+ // *kinds* of answer:
8
+ //
9
+ // 1. **The User setting** (`machines.messagesEnabled`, GrokBot's
10
+ // `gates.messagesTools`). Off is the default, and off means the tools are
11
+ // never registered — absent from the catalog rather than present and
12
+ // refusing. A capability a Bot cannot see is one it cannot be talked into
13
+ // trying.
14
+ // 2. **The device capability.** The agent reports `messages` at enrollment,
15
+ // and only a `platform: "macos"` agent may: the protocol's own enrollment
16
+ // decoder refuses it from anything else, and the desktop agent claims it
17
+ // only when the shell wired handlers behind it. No connected macOS machine
18
+ // reporting it ⇒ no registration.
19
+ // 3. **The OS permission** (`CheckIMessagePermissions`). Full Disk Access for
20
+ // `chat.db` and Automation rights over Messages.app are the User's to grant
21
+ // in System Settings; the backend can only ever *report* them. That gate is
22
+ // per call rather than per catalog, because it can change between one Turn
23
+ // and the next, and it lives in `./agent.ts` beside the call it refuses.
24
+ //
25
+ // This file holds the first two, which decide whether the tools exist at all.
26
+ // It is pure so the gate is asserted rather than inferred from a running app.
27
+ import type { MachineListEntryV1 } from "@frockbot/machine-protocol";
28
+
29
+ /** The Package setting id, as the manifest declares it. */
30
+ export const MACHINE_MESSAGES_SETTING_V1 = "messages-enabled";
31
+
32
+ /**
33
+ * The setting's value, defaulting to **off**.
34
+ *
35
+ * A setting nobody has touched is off, and anything that is not `true` is off:
36
+ * reaching into somebody's messages is not a thing to do on a value the
37
+ * resolver could not make sense of.
38
+ */
39
+ export function machineMessagesEnabledV1(
40
+ values: Readonly<Record<string, string | number | boolean>> | undefined,
41
+ ): boolean {
42
+ return values?.[MACHINE_MESSAGES_SETTING_V1] === true;
43
+ }
44
+
45
+ export type MachineMessagesGateV1 =
46
+ | { status: "off" }
47
+ | { status: "no-machine" }
48
+ | { status: "ready"; machineIds: string[] };
49
+
50
+ /** Whether one registry row can run a Messages call right now. */
51
+ export function machineMessagesCandidateV1(entry: MachineListEntryV1): boolean {
52
+ return (
53
+ entry.revokedAt === undefined &&
54
+ entry.connected &&
55
+ entry.platform === "macos" &&
56
+ entry.capabilities.includes("messages")
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Whether the Messages tools are registered for this Turn, and against which
62
+ * machines.
63
+ *
64
+ * `machineIds` is a list rather than a single id because the tools take a
65
+ * `machineId` like every other machine tool: the Bot chooses from
66
+ * `machine_list`, and this is only the fact that at least one choice exists.
67
+ */
68
+ export function machineMessagesGateV1(input: {
69
+ enabled: boolean;
70
+ machines: readonly MachineListEntryV1[];
71
+ }): MachineMessagesGateV1 {
72
+ if (!input.enabled) return { status: "off" };
73
+ const machineIds = input.machines
74
+ .filter((entry) => machineMessagesCandidateV1(entry))
75
+ .map((entry) => entry.machineId);
76
+ return machineIds.length === 0
77
+ ? { status: "no-machine" }
78
+ : { status: "ready", machineIds };
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./agent.js";
2
+ export * from "./device.js";
3
+ export * from "./gate.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Bundler",
6
+ "allowImportingTsExtensions": true,
7
+ "resolveJsonModule": true,
8
+ "strict": true,
9
+ "noEmit": true,
10
+ "skipLibCheck": true,
11
+ "lib": ["ES2023", "DOM"],
12
+ "types": ["bun"]
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @frockbot/plugin-machine-messages
2
-
3
- Placeholder reserving this name. See https://github.com/timoconnellaus/frockbot.