@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
@@ -0,0 +1,256 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { ClientSession as Session } from '@moxxy/sdk';
3
+ import type { VaultStore } from '@moxxy/plugin-vault';
4
+ import { ImessageChannel } from './channel.js';
5
+ import type { BlueBubblesClientLike } from './bluebubbles-client.js';
6
+ import {
7
+ IMESSAGE_ALLOWED_HANDLES_KEY,
8
+ IMESSAGE_OWNER_HANDLES_KEY,
9
+ IMESSAGE_SERVER_PASSWORD_KEY,
10
+ IMESSAGE_SERVER_URL_KEY,
11
+ } from './keys.js';
12
+ import { MAX_INBOUND_TEXT_CHARS } from './schema.js';
13
+
14
+ const OWNER = '+19998887777';
15
+ const FRIEND = '+15550001111';
16
+ const STRANGER = '+14440002222';
17
+ const chatFor = (handle: string): string => `iMessage;-;${handle}`;
18
+
19
+ function stubVault(seed: Record<string, string> = {}): VaultStore {
20
+ const map = new Map(Object.entries(seed));
21
+ return {
22
+ get: async (k: string) => map.get(k) ?? null,
23
+ set: async (k: string, v: string) => {
24
+ map.set(k, v);
25
+ },
26
+ has: async (k: string) => map.has(k),
27
+ delete: async (k: string) => map.delete(k),
28
+ } as unknown as VaultStore;
29
+ }
30
+
31
+ function makeFakeSession(opts: { hangTurns?: boolean } = {}) {
32
+ const listeners = new Set<(e: unknown) => void>();
33
+ const prompts: string[] = [];
34
+ let release: (() => void) | null = null;
35
+ const session = {
36
+ tools: { list: () => [{ name: 'Read' }, { name: 'Grep' }] },
37
+ transcribers: { tryGetActive: () => null },
38
+ setPermissionResolver: vi.fn(),
39
+ log: {
40
+ subscribe: (fn: (e: unknown) => void) => {
41
+ listeners.add(fn);
42
+ return () => listeners.delete(fn);
43
+ },
44
+ },
45
+ runTurn(prompt: string) {
46
+ prompts.push(prompt);
47
+ const hang = opts.hangTurns;
48
+ return (async function* () {
49
+ if (hang) {
50
+ await new Promise<void>((resolve) => {
51
+ release = resolve;
52
+ });
53
+ }
54
+ })();
55
+ },
56
+ };
57
+ return {
58
+ session: session as unknown as Session,
59
+ raw: session,
60
+ prompts,
61
+ releaseTurn: () => release?.(),
62
+ };
63
+ }
64
+
65
+ class FakeClient implements BlueBubblesClientLike {
66
+ sends: Array<{ chatGuid: string; message: string }> = [];
67
+ pinged = false;
68
+ connected = false;
69
+ closed = false;
70
+ private readonly listeners = new Set<(raw: unknown) => void>();
71
+ private n = 0;
72
+
73
+ async ping(): Promise<void> {
74
+ this.pinged = true;
75
+ }
76
+ async connect(): Promise<void> {
77
+ this.connected = true;
78
+ }
79
+ onMessage(listener: (raw: unknown) => void): () => void {
80
+ this.listeners.add(listener);
81
+ return () => this.listeners.delete(listener);
82
+ }
83
+ async sendText(chatGuid: string, message: string): Promise<{ guid: string | null; tempGuid: string }> {
84
+ this.sends.push({ chatGuid, message });
85
+ const i = ++this.n;
86
+ return { guid: `guid-${i}`, tempGuid: `temp-${i}` };
87
+ }
88
+ close(): void {
89
+ this.closed = true;
90
+ }
91
+ /** Push one inbound `new-message` payload into the channel. */
92
+ receive(raw: unknown): void {
93
+ for (const listener of this.listeners) listener(raw);
94
+ }
95
+ }
96
+
97
+ function makeChannel(overrides: { vault?: VaultStore; hangTurns?: boolean; allowedTools?: string[] } = {}) {
98
+ const client = new FakeClient();
99
+ const fake = makeFakeSession({ ...(overrides.hangTurns ? { hangTurns: true } : {}) });
100
+ const channel = new ImessageChannel({
101
+ vault:
102
+ overrides.vault ??
103
+ stubVault({
104
+ [IMESSAGE_SERVER_URL_KEY]: 'http://localhost:1234',
105
+ [IMESSAGE_SERVER_PASSWORD_KEY]: 'secret',
106
+ [IMESSAGE_ALLOWED_HANDLES_KEY]: JSON.stringify([FRIEND]),
107
+ [IMESSAGE_OWNER_HANDLES_KEY]: JSON.stringify([OWNER]),
108
+ }),
109
+ ...(overrides.allowedTools ? { allowedTools: overrides.allowedTools } : {}),
110
+ clientFactory: () => client,
111
+ });
112
+ return { channel, client, fake };
113
+ }
114
+
115
+ const inbound = (opts: {
116
+ chatGuid: string;
117
+ text?: string;
118
+ isFromMe?: boolean;
119
+ sender?: string;
120
+ guid?: string;
121
+ }): Record<string, unknown> => ({
122
+ guid: opts.guid ?? 'msg-1',
123
+ text: opts.text ?? 'hi',
124
+ isFromMe: opts.isFromMe ?? false,
125
+ ...(opts.sender ? { handle: { address: opts.sender } } : {}),
126
+ chats: [{ guid: opts.chatGuid }],
127
+ });
128
+
129
+ describe('ImessageChannel start()', () => {
130
+ it('pings, connects and swaps in the allow-list resolver on the session', async () => {
131
+ const { channel, client, fake } = makeChannel({ allowedTools: ['Read'] });
132
+ const handle = await channel.start({ session: fake.session });
133
+ expect(client.pinged).toBe(true);
134
+ expect(client.connected).toBe(true);
135
+ expect(fake.raw.setPermissionResolver).toHaveBeenCalledWith(channel.permissionResolver);
136
+ expect(channel.permissionResolver.name).toBe('imessage-allow-list');
137
+ await handle.stop();
138
+ });
139
+
140
+ it('throws a friendly error when the server is not configured', async () => {
141
+ const fake = makeFakeSession();
142
+ const channel = new ImessageChannel({ vault: stubVault(), clientFactory: () => new FakeClient() });
143
+ await expect(channel.start({ session: fake.session })).rejects.toThrow(/No BlueBubbles server configured/);
144
+ });
145
+ });
146
+
147
+ describe('inbound gating (every session-reaching path)', () => {
148
+ it('runs a turn for an allow-listed sender and replies to their chat', async () => {
149
+ const { channel, client, fake } = makeChannel();
150
+ const handle = await channel.start({ session: fake.session });
151
+ client.receive(inbound({ chatGuid: chatFor(FRIEND), sender: FRIEND, text: 'hello moxxy' }));
152
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['hello moxxy']));
153
+ await vi.waitFor(() => {
154
+ expect(client.sends.at(-1)).toEqual({ chatGuid: chatFor(FRIEND), message: '(no output)' });
155
+ });
156
+ await handle.stop();
157
+ });
158
+
159
+ it('silently drops senders that are not allow-listed', async () => {
160
+ const { channel, client, fake } = makeChannel();
161
+ const handle = await channel.start({ session: fake.session });
162
+ client.receive(inbound({ chatGuid: chatFor(STRANGER), sender: STRANGER, text: 'let me in' }));
163
+ await new Promise((r) => setTimeout(r, 20));
164
+ expect(fake.prompts).toEqual([]);
165
+ expect(client.sends).toEqual([]); // no reply — a reply would leak the bot's existence
166
+ await handle.stop();
167
+ });
168
+
169
+ it('drops group messages (v1 is direct-message only)', async () => {
170
+ const { channel, client, fake } = makeChannel();
171
+ const handle = await channel.start({ session: fake.session });
172
+ client.receive(inbound({ chatGuid: 'iMessage;+;chat123', sender: FRIEND, text: 'in a group' }));
173
+ await new Promise((r) => setTimeout(r, 20));
174
+ expect(fake.prompts).toEqual([]);
175
+ await handle.stop();
176
+ });
177
+
178
+ it('accepts the owner self-chat and replies into it', async () => {
179
+ const { channel, client, fake } = makeChannel();
180
+ const handle = await channel.start({ session: fake.session });
181
+ client.receive(inbound({ chatGuid: chatFor(OWNER), isFromMe: true, text: 'remind me to stretch' }));
182
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['remind me to stretch']));
183
+ await vi.waitFor(() => {
184
+ expect(client.sends.at(-1)).toEqual({ chatGuid: chatFor(OWNER), message: '(no output)' });
185
+ });
186
+ await handle.stop();
187
+ });
188
+
189
+ it("ignores the owner's outbound messages to other people (isFromMe foreign chat)", async () => {
190
+ const { channel, client, fake } = makeChannel();
191
+ const handle = await channel.start({ session: fake.session });
192
+ client.receive(inbound({ chatGuid: chatFor(FRIEND), isFromMe: true, text: 'private chat' }));
193
+ await new Promise((r) => setTimeout(r, 20));
194
+ expect(fake.prompts).toEqual([]);
195
+ await handle.stop();
196
+ });
197
+
198
+ it('drops echoes of its own replies (loop protection)', async () => {
199
+ const { channel, client, fake } = makeChannel();
200
+ const handle = await channel.start({ session: fake.session });
201
+
202
+ // One self-chat turn: the channel records its send ids (guid-1 / temp-1).
203
+ client.receive(inbound({ chatGuid: chatFor(OWNER), isFromMe: true, text: 'first', guid: 'in-1' }));
204
+ await vi.waitFor(() => expect(client.sends.length).toBe(1));
205
+
206
+ // BlueBubbles echoes our reply back as a new-message with our guid — must
207
+ // NOT trigger a turn (it would loop into itself in the self-chat).
208
+ client.receive(inbound({ chatGuid: chatFor(OWNER), isFromMe: true, text: '(no output)', guid: 'guid-1' }));
209
+ await new Promise((r) => setTimeout(r, 20));
210
+ expect(fake.prompts).toEqual(['first']);
211
+
212
+ // A genuinely new self-chat message still gets through.
213
+ client.receive(inbound({ chatGuid: chatFor(OWNER), isFromMe: true, text: 'second', guid: 'in-2' }));
214
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['first', 'second']));
215
+ await handle.stop();
216
+ });
217
+
218
+ it('drops schema-invalid and oversized payloads without crashing', async () => {
219
+ const { channel, client, fake } = makeChannel();
220
+ const handle = await channel.start({ session: fake.session });
221
+ client.receive(null);
222
+ client.receive('garbage');
223
+ client.receive({ guid: 'x' }); // no chats
224
+ client.receive(inbound({ chatGuid: chatFor(FRIEND), sender: FRIEND, text: 'x'.repeat(MAX_INBOUND_TEXT_CHARS + 1) }));
225
+ await new Promise((r) => setTimeout(r, 20));
226
+ expect(fake.prompts).toEqual([]);
227
+ // Still alive: a valid message afterwards works.
228
+ client.receive(inbound({ chatGuid: chatFor(FRIEND), sender: FRIEND, text: 'still alive?' }));
229
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['still alive?']));
230
+ await handle.stop();
231
+ });
232
+
233
+ it('refuses overlapping turns with a busy reply (single-flight)', async () => {
234
+ const { channel, client, fake } = makeChannel({ hangTurns: true });
235
+ const handle = await channel.start({ session: fake.session });
236
+ client.receive(inbound({ chatGuid: chatFor(FRIEND), sender: FRIEND, text: 'long task' }));
237
+ await vi.waitFor(() => expect(fake.prompts).toEqual(['long task']));
238
+ client.receive(inbound({ chatGuid: chatFor(FRIEND), sender: FRIEND, text: 'impatient follow-up', guid: 'm2' }));
239
+ await vi.waitFor(() => {
240
+ expect(client.sends.some((s) => s.message.includes('still working'))).toBe(true);
241
+ });
242
+ expect(fake.prompts).toEqual(['long task']);
243
+ fake.releaseTurn();
244
+ await handle.stop();
245
+ });
246
+ });
247
+
248
+ describe('lifecycle', () => {
249
+ it('stop() closes the client and resolves running', async () => {
250
+ const { channel, client, fake } = makeChannel();
251
+ const handle = await channel.start({ session: fake.session });
252
+ await handle.stop('test');
253
+ expect(client.closed).toBe(true);
254
+ await expect(handle.running).resolves.toBeUndefined();
255
+ });
256
+ });
package/src/channel.ts ADDED
@@ -0,0 +1,352 @@
1
+ import { newTurnId } from '@moxxy/core';
2
+ import { TurnCoordinator, resolveSecret } from '@moxxy/channel-kit';
3
+ import type { ClientSession as Session } from '@moxxy/sdk';
4
+ import type {
5
+ Channel,
6
+ ChannelHandle,
7
+ ChannelStartOptsBase,
8
+ MoxxyEvent,
9
+ PermissionResolver,
10
+ } from '@moxxy/sdk';
11
+ import type { VaultStore } from '@moxxy/plugin-vault';
12
+ import {
13
+ IMESSAGE_ALLOWED_HANDLES_KEY,
14
+ IMESSAGE_OWNER_HANDLES_KEY,
15
+ IMESSAGE_SERVER_PASSWORD_ENV,
16
+ IMESSAGE_SERVER_PASSWORD_KEY,
17
+ IMESSAGE_SERVER_URL_ENV,
18
+ IMESSAGE_SERVER_URL_KEY,
19
+ normalizeHandle,
20
+ parseHandleList,
21
+ } from './keys.js';
22
+ import { gateInboundMessage } from './message-gate.js';
23
+ import { buildImessagePermissionResolver } from './permission.js';
24
+ import {
25
+ BlueBubblesClient,
26
+ type BlueBubblesClientLike,
27
+ type BlueBubblesClientOptions,
28
+ } from './bluebubbles-client.js';
29
+ import { runImessageTurn } from './channel/turn-runner.js';
30
+ import { splitForImessage } from './channel/chunker.js';
31
+
32
+ /** Bound on remembered own-send ids (guid + tempGuid) for the echo drop. */
33
+ const MAX_SENT_IDS = 256;
34
+
35
+ /** Rate limit for "dropped inbound message" warnings. */
36
+ const DROP_WARN_INTERVAL_MS = 5_000;
37
+
38
+ /** Message when the server URL / password isn't configured yet. */
39
+ const NOT_CONFIGURED_MESSAGE =
40
+ 'No BlueBubbles server configured. Run `moxxy channels imessage setup` (or set ' +
41
+ `${IMESSAGE_SERVER_URL_ENV} + ${IMESSAGE_SERVER_PASSWORD_ENV}).`;
42
+
43
+ export interface ImessageChannelLogger {
44
+ debug?(msg: string, meta?: Record<string, unknown>): void;
45
+ info?(msg: string, meta?: Record<string, unknown>): void;
46
+ warn?(msg: string, meta?: Record<string, unknown>): void;
47
+ error?(msg: string, meta?: Record<string, unknown>): void;
48
+ }
49
+
50
+ export interface ImessageStartOpts extends ChannelStartOptsBase {
51
+ readonly session: Session;
52
+ /** Override the autonomous tool allow-list at start. */
53
+ readonly allowedTools?: ReadonlyArray<string>;
54
+ }
55
+
56
+ export interface ImessageChannelOptions {
57
+ readonly vault: VaultStore;
58
+ /** Server URL override; beats env + vault. */
59
+ readonly serverUrl?: string;
60
+ /** Server password override; beats env + vault. */
61
+ readonly password?: string;
62
+ /** Tools the model may call autonomously. `['*']` allows every registered tool. */
63
+ readonly allowedTools?: ReadonlyArray<string>;
64
+ /** Extra allow-listed handles, merged with the vault allow-list. */
65
+ readonly allowedHandles?: ReadonlyArray<string>;
66
+ /** Extra owner handles (self-chat identities), merged with the vault list. */
67
+ readonly ownerHandles?: ReadonlyArray<string>;
68
+ readonly logger?: ImessageChannelLogger;
69
+ /** Test seam: build the BlueBubbles transport (production uses the real one). */
70
+ readonly clientFactory?: (opts: BlueBubblesClientOptions) => BlueBubblesClientLike;
71
+ }
72
+
73
+ export class ImessageChannel implements Channel<ImessageStartOpts> {
74
+ readonly name = 'imessage';
75
+ /**
76
+ * Installed on the session by the CLI dispatcher. Replaced in `start()` once
77
+ * the live tool registry is available for `['*']` expansion; until then it
78
+ * denies everything (safe default before start).
79
+ */
80
+ permissionResolver: PermissionResolver;
81
+
82
+ private readonly opts: ImessageChannelOptions;
83
+ private session: Session | null = null;
84
+ private model: string | undefined;
85
+
86
+ private client: BlueBubblesClientLike | null = null;
87
+ private readonly allowedHandles = new Set<string>();
88
+ private readonly ownerHandles = new Set<string>();
89
+
90
+ // Single-flight turn state + the bounded own-turn-id set the foreign-turn
91
+ // mirror gate filters on (invariant #8).
92
+ private readonly turns = new TurnCoordinator();
93
+ private lastChatGuid: string | null = null;
94
+ private logUnsub: (() => void) | null = null;
95
+
96
+ /**
97
+ * guid + tempGuid of messages WE sent. BlueBubbles emits `new-message` for
98
+ * the account's own sends too (our replies come back `isFromMe`), so any
99
+ * inbound whose id is in this set is an echo of ourselves and must be dropped
100
+ * (loop protection).
101
+ */
102
+ private readonly sentIds = new Set<string>();
103
+
104
+ private handle: ChannelHandle | null = null;
105
+ private runningSettled = false;
106
+ private resolveRunning: (() => void) | null = null;
107
+ private stopping = false;
108
+ private lastDropWarnAt = 0;
109
+
110
+ constructor(opts: ImessageChannelOptions) {
111
+ this.opts = opts;
112
+ // Pre-start: deny-all (replaced with the real allow-list resolver in start()).
113
+ this.permissionResolver = buildImessagePermissionResolver({
114
+ allowedTools: [],
115
+ allToolNames: [],
116
+ ...(opts.logger ? { logger: opts.logger } : {}),
117
+ });
118
+ }
119
+
120
+ async start(startOpts: ImessageStartOpts): Promise<ChannelHandle> {
121
+ if (this.handle) return this.handle;
122
+ this.session = startOpts.session;
123
+ this.model = startOpts.model;
124
+
125
+ // Config: explicit option → env → vault (shared channel-kit precedence).
126
+ const serverUrl =
127
+ this.opts.serverUrl ??
128
+ (await resolveSecret(this.opts.vault, {
129
+ envVar: IMESSAGE_SERVER_URL_ENV,
130
+ vaultKey: IMESSAGE_SERVER_URL_KEY,
131
+ }));
132
+ const password =
133
+ this.opts.password ??
134
+ (await resolveSecret(this.opts.vault, {
135
+ envVar: IMESSAGE_SERVER_PASSWORD_ENV,
136
+ vaultKey: IMESSAGE_SERVER_PASSWORD_KEY,
137
+ }));
138
+ if (!serverUrl || !password) throw new Error(NOT_CONFIGURED_MESSAGE);
139
+
140
+ // Allow-list (OTHER people) + owner handles (self-chat), from vault + options.
141
+ this.rebuildHandleSet(
142
+ this.allowedHandles,
143
+ parseHandleList(await this.opts.vault.get(IMESSAGE_ALLOWED_HANDLES_KEY)),
144
+ this.opts.allowedHandles,
145
+ );
146
+ this.rebuildHandleSet(
147
+ this.ownerHandles,
148
+ parseHandleList(await this.opts.vault.get(IMESSAGE_OWNER_HANDLES_KEY)),
149
+ this.opts.ownerHandles,
150
+ );
151
+
152
+ // Swap in the real autonomous allow-list resolver now that the tool registry
153
+ // is live (mirrors Signal; `['*']` expands here).
154
+ const allowedTools = startOpts.allowedTools ?? this.opts.allowedTools ?? [];
155
+ const allToolNames = this.session.tools.list().map((t) => t.name);
156
+ this.permissionResolver = buildImessagePermissionResolver({
157
+ allowedTools,
158
+ allToolNames,
159
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
160
+ });
161
+ this.session.setPermissionResolver(this.permissionResolver);
162
+
163
+ // Mirror-to-last-chat: post assistant prose for turns this channel did NOT
164
+ // initiate (a co-attached surface ran one) into the last chat served.
165
+ this.logUnsub = this.session.log.subscribe((event) => this.mirrorForeignTurn(event));
166
+
167
+ const running = new Promise<void>((resolve) => {
168
+ this.resolveRunning = resolve;
169
+ });
170
+
171
+ const client = this.makeClient({ serverUrl, password });
172
+ this.client = client;
173
+ try {
174
+ // Reachability + auth probe deferred to here (NOT isAvailable): surfaces a
175
+ // friendly error if the localhost BlueBubbles server isn't up.
176
+ await client.ping();
177
+ await client.connect();
178
+ } catch (err) {
179
+ this.logUnsub?.();
180
+ this.logUnsub = null;
181
+ this.client = null;
182
+ throw err;
183
+ }
184
+ client.onMessage((raw) => this.handleInbound(raw));
185
+
186
+ this.handle = {
187
+ running,
188
+ stop: async (reason = 'shutdown') => {
189
+ this.stopping = true;
190
+ // Abort the in-flight turn FIRST so the model loop stops the moment the
191
+ // operator asks to shut down. The autonomous allow-list resolver has no
192
+ // pending operator prompts, so there is nothing to abortAll.
193
+ this.turns.abort(reason);
194
+ this.logUnsub?.();
195
+ this.logUnsub = null;
196
+ const client = this.client;
197
+ this.client = null;
198
+ client?.close();
199
+ this.settleRunning();
200
+ },
201
+ };
202
+ this.opts.logger?.info?.('imessage: channel started', {
203
+ allowedHandles: this.allowedHandles.size,
204
+ ownerHandles: this.ownerHandles.size,
205
+ });
206
+ return this.handle;
207
+ }
208
+
209
+ // -------------------------------------------------------------------------
210
+ // Inbound
211
+ // -------------------------------------------------------------------------
212
+
213
+ /** Every inbound `new-message` funnels through the one gate. */
214
+ private handleInbound(raw: unknown): void {
215
+ if (this.stopping) return;
216
+ const verdict = gateInboundMessage(
217
+ {
218
+ ownerHandles: this.ownerHandles,
219
+ allowedHandles: this.allowedHandles,
220
+ isOwnSend: (id) => this.sentIds.has(id),
221
+ },
222
+ raw,
223
+ );
224
+ if (!verdict.ok) {
225
+ this.warnDrop(verdict.reason);
226
+ return;
227
+ }
228
+ this.dispatchInBackground(this.processMessage(verdict.chatGuid, verdict.text), 'new-message');
229
+ }
230
+
231
+ /** Busy gate, then one coordinated turn. */
232
+ private async processMessage(chatGuid: string, text: string): Promise<void> {
233
+ if (!this.session) return;
234
+ // Atomic single-flight guard (the coordinator claims the slot BEFORE any
235
+ // await); the turnId minted here is recorded as an own-turn id — that's what
236
+ // mirrorForeignTurn filters on (invariant #8).
237
+ const lease = this.turns.begin(newTurnId());
238
+ if (!lease) {
239
+ await this.sendText(chatGuid, 'I am still working on the previous prompt. One moment…');
240
+ return;
241
+ }
242
+ this.lastChatGuid = chatGuid;
243
+ try {
244
+ await runImessageTurn(
245
+ {
246
+ session: this.session,
247
+ send: (t) => this.sendText(chatGuid, t),
248
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
249
+ },
250
+ {
251
+ text,
252
+ ...(this.model ? { model: this.model } : {}),
253
+ controller: lease.controller,
254
+ turnId: lease.turnId,
255
+ },
256
+ );
257
+ } finally {
258
+ lease.end();
259
+ }
260
+ }
261
+
262
+ // -------------------------------------------------------------------------
263
+ // Outbound
264
+ // -------------------------------------------------------------------------
265
+
266
+ /** Send one message and record its ids for the echo drop. */
267
+ private async sendText(chatGuid: string, text: string): Promise<void> {
268
+ const client = this.client;
269
+ if (!client) throw new Error('imessage client is not connected');
270
+ const { guid, tempGuid } = await client.sendText(chatGuid, text);
271
+ this.recordSent(tempGuid);
272
+ if (guid) this.recordSent(guid);
273
+ }
274
+
275
+ private recordSent(id: string): void {
276
+ this.sentIds.add(id);
277
+ if (this.sentIds.size > MAX_SENT_IDS) {
278
+ const oldest = this.sentIds.values().next().value;
279
+ if (oldest !== undefined) this.sentIds.delete(oldest);
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Post the assistant's prose for a turn this channel did not initiate.
285
+ * Skipped for our own turnIds (robust to async ordering / replay, invariant
286
+ * #8) and while a turn of ours is streaming via the chunked sender.
287
+ */
288
+ private mirrorForeignTurn(event: MoxxyEvent): void {
289
+ const text = this.turns.mirrorText(event);
290
+ if (text == null) return;
291
+ const chatGuid = this.lastChatGuid;
292
+ if (!chatGuid || !this.client) return;
293
+ void (async () => {
294
+ for (const part of splitForImessage(text)) {
295
+ await this.sendText(chatGuid, part);
296
+ }
297
+ })().catch((err) => {
298
+ this.opts.logger?.warn?.('imessage: mirror failed', { err: String(err) });
299
+ });
300
+ }
301
+
302
+ // -------------------------------------------------------------------------
303
+ // Plumbing
304
+ // -------------------------------------------------------------------------
305
+
306
+ private makeClient(opts: { serverUrl: string; password: string }): BlueBubblesClientLike {
307
+ const clientOpts: BlueBubblesClientOptions = {
308
+ serverUrl: opts.serverUrl,
309
+ password: opts.password,
310
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
311
+ };
312
+ const factory = this.opts.clientFactory ?? ((o) => new BlueBubblesClient(o));
313
+ return factory(clientOpts);
314
+ }
315
+
316
+ private rebuildHandleSet(
317
+ target: Set<string>,
318
+ fromVault: ReadonlyArray<string>,
319
+ fromOptions: ReadonlyArray<string> | undefined,
320
+ ): void {
321
+ target.clear();
322
+ for (const handle of fromVault) target.add(handle);
323
+ for (const handle of fromOptions ?? []) {
324
+ const normalized = normalizeHandle(handle);
325
+ if (normalized.length > 0) target.add(normalized);
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Run a handler detached from the socket read loop (an inbound handler that
331
+ * awaited a whole turn would park delivery for its duration). Errors are
332
+ * logged here — nothing upstream awaits this promise.
333
+ */
334
+ private dispatchInBackground(work: Promise<void>, kind: string): void {
335
+ void work.catch((err) => {
336
+ this.opts.logger?.warn?.('imessage: handler failed', { kind, err: String(err) });
337
+ });
338
+ }
339
+
340
+ private warnDrop(reason: string): void {
341
+ const now = Date.now();
342
+ if (now - this.lastDropWarnAt < DROP_WARN_INTERVAL_MS) return;
343
+ this.lastDropWarnAt = now;
344
+ this.opts.logger?.debug?.('imessage: dropped inbound message', { reason });
345
+ }
346
+
347
+ private settleRunning(): void {
348
+ if (this.runningSettled) return;
349
+ this.runningSettled = true;
350
+ this.resolveRunning?.();
351
+ }
352
+ }