@moxxy/plugin-channel-imessage 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/dist/bluebubbles-client.d.ts +79 -0
  3. package/dist/bluebubbles-client.d.ts.map +1 -0
  4. package/dist/bluebubbles-client.js +113 -0
  5. package/dist/bluebubbles-client.js.map +1 -0
  6. package/dist/channel/chunker.d.ts +66 -0
  7. package/dist/channel/chunker.d.ts.map +1 -0
  8. package/dist/channel/chunker.js +130 -0
  9. package/dist/channel/chunker.js.map +1 -0
  10. package/dist/channel/turn-runner.d.ts +32 -0
  11. package/dist/channel/turn-runner.d.ts.map +1 -0
  12. package/dist/channel/turn-runner.js +58 -0
  13. package/dist/channel/turn-runner.js.map +1 -0
  14. package/dist/channel.d.ts +87 -0
  15. package/dist/channel.d.ts.map +1 -0
  16. package/dist/channel.js +264 -0
  17. package/dist/channel.js.map +1 -0
  18. package/dist/index.d.ts +27 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +211 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/keys.d.ts +70 -0
  23. package/dist/keys.d.ts.map +1 -0
  24. package/dist/keys.js +111 -0
  25. package/dist/keys.js.map +1 -0
  26. package/dist/message-gate.d.ts +40 -0
  27. package/dist/message-gate.d.ts.map +1 -0
  28. package/dist/message-gate.js +54 -0
  29. package/dist/message-gate.js.map +1 -0
  30. package/dist/permission.d.ts +25 -0
  31. package/dist/permission.d.ts.map +1 -0
  32. package/dist/permission.js +31 -0
  33. package/dist/permission.js.map +1 -0
  34. package/dist/schema.d.ts +96 -0
  35. package/dist/schema.d.ts.map +1 -0
  36. package/dist/schema.js +41 -0
  37. package/dist/schema.js.map +1 -0
  38. package/dist/setup-wizard.d.ts +15 -0
  39. package/dist/setup-wizard.d.ts.map +1 -0
  40. package/dist/setup-wizard.js +127 -0
  41. package/dist/setup-wizard.js.map +1 -0
  42. package/package.json +96 -0
  43. package/src/bluebubbles-client.ts +182 -0
  44. package/src/channel/chunker.test.ts +132 -0
  45. package/src/channel/chunker.ts +144 -0
  46. package/src/channel/turn-runner.ts +82 -0
  47. package/src/channel.test.ts +256 -0
  48. package/src/channel.ts +352 -0
  49. package/src/index.ts +273 -0
  50. package/src/keys.test.ts +86 -0
  51. package/src/keys.ts +109 -0
  52. package/src/message-gate.test.ts +126 -0
  53. package/src/message-gate.ts +94 -0
  54. package/src/permission.ts +40 -0
  55. package/src/schema.test.ts +51 -0
  56. package/src/schema.ts +47 -0
  57. package/src/setup-wizard.ts +155 -0
  58. package/src/subcommands.test.ts +194 -0
package/package.json ADDED
@@ -0,0 +1,96 @@
1
+ {
2
+ "name": "@moxxy/plugin-channel-imessage",
3
+ "version": "0.29.0",
4
+ "description": "iMessage channel for moxxy via a localhost BlueBubbles server (macOS only). Sends text with the apple-script method and receives via the BlueBubbles socket.io feed, driving the Session from allow-listed handles (your own self-chat by default). Immutable chunked replies; autonomous allow-list permission model.",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "channel",
9
+ "imessage",
10
+ "bluebubbles",
11
+ "bot"
12
+ ],
13
+ "homepage": "https://moxxy.ai",
14
+ "bugs": {
15
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
20
+ "directory": "packages/plugin-channel-imessage"
21
+ },
22
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
23
+ "license": "MIT",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "type": "module",
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.ts",
33
+ "import": "./dist/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "src"
39
+ ],
40
+ "moxxy": {
41
+ "plugin": {
42
+ "entry": "./dist/index.js",
43
+ "kind": "channel"
44
+ },
45
+ "requirements": [
46
+ {
47
+ "kind": "plugin",
48
+ "name": "@moxxy/plugin-vault",
49
+ "hint": "imessage resolves the vault from the service registry for its BlueBubbles server URL + password + handle allow-list; @moxxy/plugin-vault must load first"
50
+ }
51
+ ],
52
+ "setup": {
53
+ "title": "iMessage (BlueBubbles)",
54
+ "description": "macOS only. Install the BlueBubbles server (https://bluebubbles.app) on this Mac, sign in to Messages, and set a server password. Then paste the server URL (default http://localhost:1234) and the password below, and run `moxxy channels imessage setup` to add the handles allowed to talk to moxxy.",
55
+ "fields": [
56
+ {
57
+ "key": "serverUrl",
58
+ "label": "BlueBubbles server URL",
59
+ "kind": "text",
60
+ "vaultKey": "imessage_server_url",
61
+ "required": true,
62
+ "placeholder": "http://localhost:1234"
63
+ },
64
+ {
65
+ "key": "password",
66
+ "label": "BlueBubbles server password",
67
+ "kind": "secret",
68
+ "vaultKey": "imessage_server_password",
69
+ "required": true
70
+ }
71
+ ]
72
+ }
73
+ },
74
+ "dependencies": {
75
+ "@clack/prompts": "^1.4.0",
76
+ "socket.io-client": "^4.8.1",
77
+ "zod": "^3.24.0",
78
+ "@moxxy/core": "0.29.0",
79
+ "@moxxy/plugin-vault": "0.29.0",
80
+ "@moxxy/channel-kit": "0.29.0",
81
+ "@moxxy/sdk": "0.29.0"
82
+ },
83
+ "devDependencies": {
84
+ "@types/node": "^22.10.0",
85
+ "typescript": "^5.7.3",
86
+ "vitest": "^2.1.8",
87
+ "@moxxy/tsconfig": "0.0.0",
88
+ "@moxxy/vitest-preset": "0.0.0"
89
+ },
90
+ "scripts": {
91
+ "build": "tsc -p tsconfig.json",
92
+ "typecheck": "tsc -p tsconfig.json --noEmit",
93
+ "test": "vitest run",
94
+ "clean": "rm -rf dist .turbo"
95
+ }
96
+ }
@@ -0,0 +1,182 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ /**
4
+ * The BlueBubbles transport seam (the "sidecar" equivalent for iMessage).
5
+ *
6
+ * BlueBubbles is a native macOS app running a localhost Koa + socket.io server
7
+ * (default port 1234). We hit it directly over `http://127.0.0.1:1234`:
8
+ * - inbound: a socket.io client subscribed to `new-message` (built-in
9
+ * reconnection; no webhook lifecycle),
10
+ * - outbound: `POST /api/v1/message/text` with `method: 'apple-script'`.
11
+ *
12
+ * Auth is a single password passed as a QUERY PARAM on every call (BlueBubbles
13
+ * never reads it from a header). There is no HMAC on the socket feed — pure
14
+ * localhost trust — so the consumer must stay bound to loopback.
15
+ *
16
+ * The channel drives this behind {@link BlueBubblesClientLike} so tests inject a
17
+ * fake and no socket.io / network is touched. `socket.io-client` is imported
18
+ * lazily inside {@link BlueBubblesClient.connect} so module load (and
19
+ * `isAvailable`) stays light.
20
+ */
21
+ export interface BlueBubblesClientLike {
22
+ /** Best-effort reachability + auth probe; throws a friendly error on failure. */
23
+ ping(): Promise<void>;
24
+ /** Open the socket.io connection and begin emitting inbound messages. */
25
+ connect(): Promise<void>;
26
+ /** Subscribe to raw `new-message` payloads. Returns an unsubscribe fn. */
27
+ onMessage(listener: (raw: unknown) => void): () => void;
28
+ /**
29
+ * Send a text message. `tempGuid` correlates the send with its echoed
30
+ * `new-message`; the returned `guid` (when the server reports it) is the
31
+ * permanent id. Both feed the anti-echo set.
32
+ */
33
+ sendText(chatGuid: string, message: string): Promise<{ guid: string | null; tempGuid: string }>;
34
+ /** Tear down the socket. */
35
+ close(): void;
36
+ }
37
+
38
+ export interface BlueBubblesClientLogger {
39
+ debug?(msg: string, meta?: Record<string, unknown>): void;
40
+ info?(msg: string, meta?: Record<string, unknown>): void;
41
+ warn?(msg: string, meta?: Record<string, unknown>): void;
42
+ }
43
+
44
+ /** Minimal slice of a socket.io client the channel uses (keeps typing loose). */
45
+ export interface SocketLike {
46
+ on(event: string, listener: (...args: unknown[]) => void): void;
47
+ close(): void;
48
+ }
49
+
50
+ export interface BlueBubblesClientOptions {
51
+ readonly serverUrl: string;
52
+ readonly password: string;
53
+ readonly logger?: BlueBubblesClientLogger;
54
+ /** Test seam: injectable fetch (defaults to global fetch). */
55
+ readonly fetchImpl?: typeof fetch;
56
+ /** Test seam: injectable socket factory (defaults to lazy socket.io-client). */
57
+ readonly socketFactory?: (opts: { url: string; password: string }) => Promise<SocketLike>;
58
+ }
59
+
60
+ /** A fresh per-send temp guid (correlates our send with its echoed message). */
61
+ export function makeTempGuid(): string {
62
+ return `temp-${randomUUID()}`;
63
+ }
64
+
65
+ export class BlueBubblesClient implements BlueBubblesClientLike {
66
+ private readonly opts: BlueBubblesClientOptions;
67
+ private readonly base: string;
68
+ private socket: SocketLike | null = null;
69
+ private readonly messageListeners = new Set<(raw: unknown) => void>();
70
+
71
+ constructor(opts: BlueBubblesClientOptions) {
72
+ this.opts = opts;
73
+ // Normalize the base URL once (strip a trailing slash) so path joins are clean.
74
+ this.base = opts.serverUrl.trim().replace(/\/+$/, '');
75
+ }
76
+
77
+ async ping(): Promise<void> {
78
+ const doFetch = this.opts.fetchImpl ?? fetch;
79
+ let res: Response;
80
+ try {
81
+ res = await doFetch(this.apiUrl('/api/v1/ping'));
82
+ } catch (err) {
83
+ throw new Error(
84
+ `Cannot reach the BlueBubbles server at ${this.base}. Is it running on this Mac? ` +
85
+ `(${err instanceof Error ? err.message : String(err)})`,
86
+ );
87
+ }
88
+ if (res.status === 401 || res.status === 403) {
89
+ throw new Error(
90
+ 'The BlueBubbles server rejected the password. Check the password in the BlueBubbles app matches the one stored for moxxy.',
91
+ );
92
+ }
93
+ // Any other HTTP response means something is listening — reachable enough.
94
+ }
95
+
96
+ async connect(): Promise<void> {
97
+ const factory = this.opts.socketFactory ?? ((o) => defaultSocketFactory(o));
98
+ const socket = await factory({ url: this.base, password: this.opts.password });
99
+ this.socket = socket;
100
+ socket.on('new-message', (...args: unknown[]) => {
101
+ const payload = args.length > 0 ? args[0] : undefined;
102
+ for (const listener of this.messageListeners) {
103
+ try {
104
+ listener(payload);
105
+ } catch (err) {
106
+ this.opts.logger?.warn?.('imessage: message listener threw', { err: String(err) });
107
+ }
108
+ }
109
+ });
110
+ socket.on('disconnect', (...args: unknown[]) => {
111
+ this.opts.logger?.debug?.('imessage: socket disconnected (auto-reconnecting)', {
112
+ reason: String(args.length > 0 ? args[0] : ''),
113
+ });
114
+ });
115
+ }
116
+
117
+ onMessage(listener: (raw: unknown) => void): () => void {
118
+ this.messageListeners.add(listener);
119
+ return () => this.messageListeners.delete(listener);
120
+ }
121
+
122
+ async sendText(
123
+ chatGuid: string,
124
+ message: string,
125
+ ): Promise<{ guid: string | null; tempGuid: string }> {
126
+ const doFetch = this.opts.fetchImpl ?? fetch;
127
+ const tempGuid = makeTempGuid();
128
+ const res = await doFetch(this.apiUrl('/api/v1/message/text'), {
129
+ method: 'POST',
130
+ headers: { 'content-type': 'application/json' },
131
+ body: JSON.stringify({ chatGuid, tempGuid, message, method: 'apple-script' }),
132
+ });
133
+ if (!res.ok) {
134
+ const detail = await res.text().catch(() => '');
135
+ throw new Error(`BlueBubbles send failed (HTTP ${res.status})${detail ? `: ${detail}` : ''}`);
136
+ }
137
+ const guid = await extractGuid(res);
138
+ return { guid, tempGuid };
139
+ }
140
+
141
+ close(): void {
142
+ this.messageListeners.clear();
143
+ const socket = this.socket;
144
+ this.socket = null;
145
+ if (socket) {
146
+ try {
147
+ socket.close();
148
+ } catch (err) {
149
+ this.opts.logger?.warn?.('imessage: socket close threw', { err: String(err) });
150
+ }
151
+ }
152
+ }
153
+
154
+ /** Build an absolute API URL with the password query param appended. */
155
+ private apiUrl(path: string): string {
156
+ const url = new URL(this.base + path);
157
+ url.searchParams.set('password', this.opts.password);
158
+ return url.toString();
159
+ }
160
+ }
161
+
162
+ /** Read the sent message's permanent guid from the send response, if present. */
163
+ async function extractGuid(res: Response): Promise<string | null> {
164
+ const body: unknown = await res.json().catch(() => null);
165
+ if (!body || typeof body !== 'object') return null;
166
+ const data = (body as { data?: unknown }).data;
167
+ if (!data || typeof data !== 'object') return null;
168
+ const guid = (data as { guid?: unknown }).guid;
169
+ return typeof guid === 'string' && guid.length > 0 ? guid : null;
170
+ }
171
+
172
+ /** Lazily import socket.io-client and open a connection to the BlueBubbles feed. */
173
+ async function defaultSocketFactory(opts: { url: string; password: string }): Promise<SocketLike> {
174
+ const { io } = await import('socket.io-client');
175
+ const socket = io(opts.url, {
176
+ query: { password: opts.password },
177
+ transports: ['websocket'],
178
+ // BlueBubbles' socket.io has built-in reconnection; keep it on.
179
+ reconnection: true,
180
+ });
181
+ return socket as unknown as SocketLike;
182
+ }
@@ -0,0 +1,132 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ ChunkedSender,
4
+ IMESSAGE_CHUNK_HARD_LIMIT,
5
+ splitForImessage,
6
+ takeChunk,
7
+ } from './chunker.js';
8
+
9
+ describe('takeChunk', () => {
10
+ it('holds short text for the final flush', () => {
11
+ expect(takeChunk('hello world')).toBeNull();
12
+ expect(takeChunk('x'.repeat(1_500))).toBeNull();
13
+ });
14
+
15
+ it('splits at a paragraph boundary once past the soft limit', () => {
16
+ const text = 'a'.repeat(1_200) + '\n\n' + 'b'.repeat(600);
17
+ const taken = takeChunk(text);
18
+ expect(taken).not.toBeNull();
19
+ expect(taken?.chunk).toBe('a'.repeat(1_200));
20
+ expect(taken?.rest).toBe('b'.repeat(600));
21
+ });
22
+
23
+ it('falls back to newline, then word boundaries', () => {
24
+ const nl = 'a'.repeat(1_200) + '\n' + 'b'.repeat(600);
25
+ expect(takeChunk(nl)?.chunk).toBe('a'.repeat(1_200));
26
+ const word = 'a'.repeat(1_200) + ' ' + 'b'.repeat(600);
27
+ expect(takeChunk(word)?.chunk).toBe('a'.repeat(1_200));
28
+ });
29
+
30
+ it('never returns a chunk above the hard limit (hard cut when boundary-less)', () => {
31
+ const text = 'x'.repeat(IMESSAGE_CHUNK_HARD_LIMIT + 500);
32
+ const taken = takeChunk(text);
33
+ expect(taken).not.toBeNull();
34
+ expect(taken?.chunk.length).toBe(IMESSAGE_CHUNK_HARD_LIMIT);
35
+ expect(taken?.rest.length).toBe(500);
36
+ });
37
+
38
+ it('waits for more text when boundary-less but under the hard limit', () => {
39
+ expect(takeChunk('x'.repeat(1_800))).toBeNull();
40
+ });
41
+
42
+ it('ignores boundaries too early to form a meaningful chunk', () => {
43
+ const text = '\n\n' + 'x'.repeat(1_700);
44
+ expect(takeChunk(text)).toBeNull();
45
+ });
46
+ });
47
+
48
+ describe('splitForImessage', () => {
49
+ it('returns short text as a single message', () => {
50
+ expect(splitForImessage('hi')).toEqual(['hi']);
51
+ });
52
+
53
+ it('drops pure whitespace', () => {
54
+ expect(splitForImessage(' \n ')).toEqual([]);
55
+ });
56
+
57
+ it('splits long text into under-limit pieces', () => {
58
+ const paragraphs = Array.from({ length: 8 }, (_, i) => `${'p'.repeat(700)}${i}`).join('\n\n');
59
+ const parts = splitForImessage(paragraphs);
60
+ expect(parts.length).toBeGreaterThan(1);
61
+ for (const part of parts) expect(part.length).toBeLessThanOrEqual(IMESSAGE_CHUNK_HARD_LIMIT);
62
+ expect(parts.join('')).toContain('p'.repeat(700) + '7');
63
+ });
64
+ });
65
+
66
+ describe('ChunkedSender', () => {
67
+ function collector(): { sent: string[]; send: (t: string) => Promise<void> } {
68
+ const sent: string[] = [];
69
+ return {
70
+ sent,
71
+ send: async (t) => {
72
+ sent.push(t);
73
+ },
74
+ };
75
+ }
76
+
77
+ it('buffers below the soft limit and flushes once on finalize', async () => {
78
+ const { sent, send } = collector();
79
+ const sender = new ChunkedSender({ send });
80
+ sender.offer('Hello');
81
+ sender.offer('Hello world');
82
+ await sender.finalize('Hello world, done.');
83
+ expect(sent).toEqual(['Hello world, done.']);
84
+ });
85
+
86
+ it('streams paragraph chunks as the snapshot grows, then sends only the remainder', async () => {
87
+ const { sent, send } = collector();
88
+ const sender = new ChunkedSender({ send, limits: { softLimit: 20, hardLimit: 60 } });
89
+ sender.offer('first paragraph text\n\nsecond');
90
+ await sender.finalize('first paragraph text\n\nsecond part is done');
91
+ expect(sent).toEqual(['first paragraph text', 'second part is done']);
92
+ });
93
+
94
+ it('sends the empty-turn placeholder only when nothing was ever sent', async () => {
95
+ const { sent, send } = collector();
96
+ const sender = new ChunkedSender({ send });
97
+ await sender.finalize(' ', '(no output)');
98
+ expect(sent).toEqual(['(no output)']);
99
+ });
100
+
101
+ it('does not send the placeholder when chunks already went out', async () => {
102
+ const { sent, send } = collector();
103
+ const sender = new ChunkedSender({ send, limits: { softLimit: 5, hardLimit: 30 } });
104
+ sender.offer('streamed text already sent\n\nx');
105
+ await sender.finalize('', '(no output)');
106
+ expect(sent).toEqual(['streamed text already sent']);
107
+ });
108
+
109
+ it('re-sends the authoritative final text when it diverges from the streamed prefix', async () => {
110
+ const { sent, send } = collector();
111
+ const sender = new ChunkedSender({ send, limits: { softLimit: 10, hardLimit: 40 } });
112
+ sender.offer('streamed draft one\n\nmore text here');
113
+ await sender.finalize('a completely different final answer');
114
+ expect(sent[0]).toBe('streamed draft one');
115
+ expect(sent).toContain('a completely different final answer');
116
+ });
117
+
118
+ it('serializes sends in order', async () => {
119
+ const order: string[] = [];
120
+ const sender = new ChunkedSender({
121
+ send: async (t) => {
122
+ await new Promise((r) => setTimeout(r, 5));
123
+ order.push(t);
124
+ },
125
+ limits: { softLimit: 4, hardLimit: 12 },
126
+ });
127
+ sender.offer('one two\n\n');
128
+ sender.offer('one two\n\nthree four\n\n');
129
+ await sender.finalize('one two\n\nthree four\n\nfive');
130
+ expect(order).toEqual(['one two', 'three four', 'five']);
131
+ });
132
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Buffered chunked sends — iMessage's streaming strategy.
3
+ *
4
+ * WHY NOT FramePump edits: editing a sent iMessage requires the BlueBubbles
5
+ * Private API (a Helper bundle injected into Messages, which needs SIP disabled)
6
+ * — out of scope for v1's stock apple-script server. With apple-script every
7
+ * message is immutable once sent, so instead of "send once then edit" we buffer
8
+ * the streamed text and send coherent chunks: nothing goes out until a
9
+ * paragraph-aligned chunk is ready, and the remainder goes out once, at turn
10
+ * end. (Copied from the Signal channel's chunker, which faces the same
11
+ * immutable-message constraint.)
12
+ */
13
+
14
+ /** Buffered text below this length is held for the final flush. */
15
+ export const IMESSAGE_CHUNK_SOFT_LIMIT = 1_500;
16
+ /**
17
+ * Never send a single message longer than this. iMessage itself allows long
18
+ * bodies, but clients render extremely long messages poorly; 2000 mirrors the
19
+ * Signal channel's cap for parity.
20
+ */
21
+ export const IMESSAGE_CHUNK_HARD_LIMIT = 2_000;
22
+
23
+ export interface ChunkLimits {
24
+ readonly softLimit?: number;
25
+ readonly hardLimit?: number;
26
+ }
27
+
28
+ /**
29
+ * If `text` has grown past the soft limit, split off one send-ready chunk at
30
+ * the best boundary (paragraph → line → word) at or below the hard limit.
31
+ * Returns null while the text should keep buffering.
32
+ */
33
+ export function takeChunk(
34
+ text: string,
35
+ limits: ChunkLimits = {},
36
+ ): { chunk: string; rest: string } | null {
37
+ const soft = limits.softLimit ?? IMESSAGE_CHUNK_SOFT_LIMIT;
38
+ const hard = limits.hardLimit ?? IMESSAGE_CHUNK_HARD_LIMIT;
39
+ if (text.length <= soft) return null;
40
+ const window = text.slice(0, Math.min(text.length, hard));
41
+ // Prefer a paragraph break; a chunk that ends mid-sentence reads badly as a
42
+ // standalone message. Require the boundary to keep a meaningful chunk (≥ half
43
+ // the soft limit) so a leading blank line can't produce confetti.
44
+ const minCut = Math.floor(soft / 2);
45
+ for (const boundary of ['\n\n', '\n', ' ']) {
46
+ const at = window.lastIndexOf(boundary);
47
+ if (at >= minCut) {
48
+ return { chunk: window.slice(0, at).trimEnd(), rest: text.slice(at + boundary.length) };
49
+ }
50
+ }
51
+ if (text.length <= hard) return null; // no boundary yet — wait for more text
52
+ return { chunk: window, rest: text.slice(window.length) }; // pathological: hard cut
53
+ }
54
+
55
+ /** Split a final remainder into hard-limit-sized pieces at the best boundaries. */
56
+ export function splitForImessage(text: string, limits: ChunkLimits = {}): string[] {
57
+ const hard = limits.hardLimit ?? IMESSAGE_CHUNK_HARD_LIMIT;
58
+ const parts: string[] = [];
59
+ let rest = text;
60
+ while (rest.length > hard) {
61
+ // Reuse takeChunk with soft==hard-ish so it only splits when forced.
62
+ const taken = takeChunk(rest, { softLimit: Math.floor(hard / 2), hardLimit: hard });
63
+ if (!taken) break;
64
+ if (taken.chunk) parts.push(taken.chunk);
65
+ rest = taken.rest;
66
+ }
67
+ const tail = rest.trim();
68
+ if (tail) parts.push(tail);
69
+ return parts;
70
+ }
71
+
72
+ export interface ChunkedSenderOptions {
73
+ /** Deliver one message. Errors should be handled by the caller-supplied fn
74
+ * (log + swallow) — a failed chunk must never abort the turn. */
75
+ readonly send: (text: string) => Promise<void>;
76
+ readonly limits?: ChunkLimits;
77
+ }
78
+
79
+ /**
80
+ * Drives {@link takeChunk} over a GROWING renderer snapshot: `offer()` on every
81
+ * change (sends any ready chunk), `finalize()` at turn end (sends the
82
+ * remainder, or `emptyText` when the whole turn produced nothing). Sends are
83
+ * serialized on an internal queue so chunks can never interleave out of order.
84
+ */
85
+ export class ChunkedSender {
86
+ private readonly opts: ChunkedSenderOptions;
87
+ /** The exact snapshot prefix already delivered (chunk boundaries included). */
88
+ private sentPrefix = '';
89
+ private sentAnything = false;
90
+ private queue: Promise<void> = Promise.resolve();
91
+
92
+ constructor(opts: ChunkedSenderOptions) {
93
+ this.opts = opts;
94
+ }
95
+
96
+ /** Called with the current full snapshot whenever it changes. */
97
+ offer(snapshot: string): void {
98
+ this.queue = this.queue.then(async () => {
99
+ // Streamed text only ever grows; if the snapshot diverged from what we
100
+ // already delivered (the final assistant_message rewrote history) hold
101
+ // everything for finalize(), which handles divergence explicitly.
102
+ if (!snapshot.startsWith(this.sentPrefix)) return;
103
+ let consumed = this.sentPrefix.length;
104
+ let taken = takeChunk(snapshot.slice(consumed), this.opts.limits);
105
+ while (taken) {
106
+ await this.deliver(taken.chunk);
107
+ consumed = snapshot.length - taken.rest.length;
108
+ this.sentPrefix = snapshot.slice(0, consumed);
109
+ taken = takeChunk(taken.rest, this.opts.limits);
110
+ }
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Turn end: deliver whatever the final snapshot still owes. When the final
116
+ * text no longer extends what we already sent (a divergent final frame —
117
+ * rare), send the full final text so the user always receives the
118
+ * authoritative reply, accepting the duplication.
119
+ */
120
+ async finalize(snapshot: string, emptyText?: string): Promise<void> {
121
+ this.queue = this.queue.then(async () => {
122
+ const finalText = snapshot.trim();
123
+ if (!finalText) {
124
+ if (!this.sentAnything && emptyText) await this.deliver(emptyText);
125
+ return;
126
+ }
127
+ const remainder = snapshot.startsWith(this.sentPrefix)
128
+ ? snapshot.slice(this.sentPrefix.length)
129
+ : snapshot; // diverged: resend the authoritative text in full
130
+ for (const part of splitForImessage(remainder, this.opts.limits)) {
131
+ await this.deliver(part);
132
+ }
133
+ this.sentPrefix = snapshot;
134
+ });
135
+ await this.queue;
136
+ }
137
+
138
+ private async deliver(text: string): Promise<void> {
139
+ const trimmed = text.trim();
140
+ if (!trimmed) return;
141
+ this.sentAnything = true;
142
+ await this.opts.send(trimmed);
143
+ }
144
+ }
@@ -0,0 +1,82 @@
1
+ import type { newTurnId } from '@moxxy/core';
2
+ import { PlainTurnRenderer, driveTurn, subscribeTurn } from '@moxxy/channel-kit';
3
+ import type { ClientSession as Session } from '@moxxy/sdk';
4
+ import { ChunkedSender, type ChunkLimits } from './chunker.js';
5
+
6
+ export interface TurnRunnerLogger {
7
+ warn?(msg: string, meta?: Record<string, unknown>): void;
8
+ }
9
+
10
+ export interface RunImessageTurnDeps {
11
+ readonly session: Session;
12
+ /** Deliver one outbound message to the turn's reply target. Must swallow its
13
+ * own transport errors (log + resolve) — a failed send never aborts the turn. */
14
+ readonly send: (text: string) => Promise<void>;
15
+ readonly chunkLimits?: ChunkLimits;
16
+ readonly logger?: TurnRunnerLogger;
17
+ }
18
+
19
+ export interface RunImessageTurnOptions {
20
+ readonly text: string;
21
+ readonly model?: string;
22
+ readonly controller: AbortController;
23
+ /** Pre-minted turn id; the channel records it as an own-turn id (invariant #8). */
24
+ readonly turnId: ReturnType<typeof newTurnId>;
25
+ }
26
+
27
+ /**
28
+ * Drive a single iMessage turn end-to-end: subscribe to THIS turn's events
29
+ * (filtered by turnId — `session.log` fans out to every listener, AGENTS.md
30
+ * invariant #8), stream the rendered snapshot through the buffered
31
+ * {@link ChunkedSender} (see chunker.ts for why iMessage gets chunked sends
32
+ * instead of FramePump edits — apple-script messages are immutable), run the
33
+ * turn, flush the final remainder, and unwind in `finally`. No typing indicator:
34
+ * that needs the BlueBubbles Private API, which v1 deliberately omits.
35
+ */
36
+ export async function runImessageTurn(
37
+ deps: RunImessageTurnDeps,
38
+ opts: RunImessageTurnOptions,
39
+ ): Promise<void> {
40
+ const { session, send, logger } = deps;
41
+ const { text, model, controller, turnId } = opts;
42
+
43
+ const renderer = new PlainTurnRenderer();
44
+ const sender = new ChunkedSender({
45
+ send: async (t) => {
46
+ try {
47
+ await send(t);
48
+ } catch (err) {
49
+ logger?.warn?.('imessage: send failed', {
50
+ err: err instanceof Error ? err.message : String(err),
51
+ });
52
+ }
53
+ },
54
+ ...(deps.chunkLimits ? { limits: deps.chunkLimits } : {}),
55
+ });
56
+
57
+ const unsubscribe = subscribeTurn(session, turnId, (event) => {
58
+ if (renderer.accept(event)) sender.offer(renderer.snapshot());
59
+ });
60
+
61
+ try {
62
+ await driveTurn(session, {
63
+ turnId,
64
+ prompt: text,
65
+ ...(model ? { model } : {}),
66
+ signal: controller.signal,
67
+ });
68
+ await sender.finalize(renderer.snapshot(), '(no output)');
69
+ } catch (err) {
70
+ logger?.warn?.('imessage: turn failed', {
71
+ err: err instanceof Error ? err.message : String(err),
72
+ });
73
+ // Surface the failure to the sender rather than going silent.
74
+ try {
75
+ await send(`Turn failed: ${err instanceof Error ? err.message : String(err)}`);
76
+ } catch {
77
+ /* best-effort */
78
+ }
79
+ } finally {
80
+ unsubscribe();
81
+ }
82
+ }