@moxxy/plugin-channel-signal 0.27.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/dist/channel/chunker.d.ts +69 -0
  3. package/dist/channel/chunker.d.ts.map +1 -0
  4. package/dist/channel/chunker.js +133 -0
  5. package/dist/channel/chunker.js.map +1 -0
  6. package/dist/channel/turn-runner.d.ts +33 -0
  7. package/dist/channel/turn-runner.d.ts.map +1 -0
  8. package/dist/channel/turn-runner.js +70 -0
  9. package/dist/channel/turn-runner.js.map +1 -0
  10. package/dist/channel/voice.d.ts +37 -0
  11. package/dist/channel/voice.d.ts.map +1 -0
  12. package/dist/channel/voice.js +88 -0
  13. package/dist/channel/voice.js.map +1 -0
  14. package/dist/channel.d.ts +154 -0
  15. package/dist/channel.d.ts.map +1 -0
  16. package/dist/channel.js +468 -0
  17. package/dist/channel.js.map +1 -0
  18. package/dist/index.d.ts +28 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +215 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/jsonrpc.d.ts +53 -0
  23. package/dist/jsonrpc.d.ts.map +1 -0
  24. package/dist/jsonrpc.js +167 -0
  25. package/dist/jsonrpc.js.map +1 -0
  26. package/dist/keys.d.ts +37 -0
  27. package/dist/keys.d.ts.map +1 -0
  28. package/dist/keys.js +55 -0
  29. package/dist/keys.js.map +1 -0
  30. package/dist/pair-flow.d.ts +21 -0
  31. package/dist/pair-flow.d.ts.map +1 -0
  32. package/dist/pair-flow.js +136 -0
  33. package/dist/pair-flow.js.map +1 -0
  34. package/dist/permission.d.ts +21 -0
  35. package/dist/permission.d.ts.map +1 -0
  36. package/dist/permission.js +27 -0
  37. package/dist/permission.js.map +1 -0
  38. package/dist/schema.d.ts +4295 -0
  39. package/dist/schema.d.ts.map +1 -0
  40. package/dist/schema.js +84 -0
  41. package/dist/schema.js.map +1 -0
  42. package/dist/setup-wizard.d.ts +14 -0
  43. package/dist/setup-wizard.d.ts.map +1 -0
  44. package/dist/setup-wizard.js +85 -0
  45. package/dist/setup-wizard.js.map +1 -0
  46. package/dist/sidecar.d.ts +137 -0
  47. package/dist/sidecar.d.ts.map +1 -0
  48. package/dist/sidecar.js +421 -0
  49. package/dist/sidecar.js.map +1 -0
  50. package/package.json +89 -0
  51. package/src/channel/chunker.test.ts +135 -0
  52. package/src/channel/chunker.ts +147 -0
  53. package/src/channel/turn-runner.ts +97 -0
  54. package/src/channel/voice.test.ts +136 -0
  55. package/src/channel/voice.ts +118 -0
  56. package/src/channel.test.ts +364 -0
  57. package/src/channel.ts +607 -0
  58. package/src/index.ts +289 -0
  59. package/src/jsonrpc.test.ts +128 -0
  60. package/src/jsonrpc.ts +198 -0
  61. package/src/keys.test.ts +45 -0
  62. package/src/keys.ts +56 -0
  63. package/src/pair-flow.ts +161 -0
  64. package/src/permission.ts +36 -0
  65. package/src/schema.ts +98 -0
  66. package/src/setup-wizard.ts +98 -0
  67. package/src/sidecar.test.ts +276 -0
  68. package/src/sidecar.ts +511 -0
  69. package/src/subcommands.test.ts +206 -0
package/src/channel.ts ADDED
@@ -0,0 +1,607 @@
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
+ SIGNAL_ACCOUNT_ENV,
14
+ SIGNAL_ACCOUNT_KEY,
15
+ SIGNAL_ALLOWED_SENDERS_KEY,
16
+ normalizeSender,
17
+ parseAllowedSenders,
18
+ } from './keys.js';
19
+ import {
20
+ receiveParamsSchema,
21
+ type SignalAttachment,
22
+ type SignalEnvelope,
23
+ } from './schema.js';
24
+ import {
25
+ SIGNAL_CLI_INSTALL_HINT,
26
+ SignalSidecar,
27
+ findSignalCliOnPath,
28
+ listSignalAccounts,
29
+ signalCliAttachmentsDir,
30
+ startLinkProcess,
31
+ type LinkProcessHandle,
32
+ type SpawnFn,
33
+ } from './sidecar.js';
34
+ import { buildSignalPermissionResolver } from './permission.js';
35
+ import { runSignalTurn } from './channel/turn-runner.js';
36
+ import { splitForSignal } from './channel/chunker.js';
37
+ import { pickAudioAttachment, transcribeVoiceAttachment } from './channel/voice.js';
38
+
39
+ /** Device name shown in the phone's "Linked Devices" list. */
40
+ const LINK_DEVICE_NAME = 'moxxy';
41
+
42
+ /** Bound on remembered own-send timestamps for the sync-echo drop. */
43
+ const MAX_SENT_TIMESTAMPS = 256;
44
+
45
+ /** Rate limit for "dropped invalid/unauthorized envelope" warnings. */
46
+ const DROP_WARN_INTERVAL_MS = 5_000;
47
+
48
+ export interface SignalChannelLogger {
49
+ debug?(msg: string, meta?: Record<string, unknown>): void;
50
+ info?(msg: string, meta?: Record<string, unknown>): void;
51
+ warn?(msg: string, meta?: Record<string, unknown>): void;
52
+ error?(msg: string, meta?: Record<string, unknown>): void;
53
+ }
54
+
55
+ /** Where a reply goes: the owner's Note-to-Self thread, or a direct recipient. */
56
+ export type SendTarget = { readonly noteToSelf: true } | { readonly recipient: string };
57
+
58
+ /** The RPC slice the channel drives (SignalRpcClient satisfies it; tests fake it). */
59
+ export interface SignalRpcLike {
60
+ request(method: string, params?: Record<string, unknown>): Promise<unknown>;
61
+ onNotification(method: string, listener: (params: unknown) => void): () => void;
62
+ onClose(listener: (reason: string) => void): () => void;
63
+ close(): void;
64
+ }
65
+
66
+ /** The sidecar slice the channel manages (SignalSidecar satisfies it). */
67
+ export interface SignalSidecarLike {
68
+ start(): Promise<SignalRpcLike>;
69
+ stop(): Promise<void>;
70
+ onExit(listener: (code: number | null) => void): () => void;
71
+ }
72
+
73
+ export interface SignalStartOpts extends ChannelStartOptsBase {
74
+ readonly session: Session;
75
+ /**
76
+ * If true (and the account isn't linked yet), open a linking window on
77
+ * startup: spawn `signal-cli link`, publish the `sgnl://linkdevice…` URI as
78
+ * this channel's connect value (rendered as a QR by the pair flow / desktop
79
+ * Channels panel), and boot the daemon once the phone completes the link.
80
+ */
81
+ readonly pair?: boolean;
82
+ /**
83
+ * Running on a dedicated runner under a GUI control surface (the desktop
84
+ * Channels panel). Equivalent to `pair` for the unlinked case, so the desktop
85
+ * links with the identical mechanism instead of getting a throw.
86
+ */
87
+ readonly dedicated?: boolean;
88
+ /** Override the autonomous tool allow-list at start. */
89
+ readonly allowedTools?: ReadonlyArray<string>;
90
+ }
91
+
92
+ export interface SignalChannelOptions {
93
+ readonly vault: VaultStore;
94
+ /** Account override (E.164); beats env + vault. */
95
+ readonly account?: string;
96
+ /** signal-cli binary path/name override. Default: resolve from PATH. */
97
+ readonly binary?: string;
98
+ /** Tools the model may call autonomously. `['*']` allows every registered tool. */
99
+ readonly allowedTools?: ReadonlyArray<string>;
100
+ /** Extra allowed senders (E.164/uuid), merged with the vault allow-list. */
101
+ readonly allowedSenders?: ReadonlyArray<string>;
102
+ readonly logger?: SignalChannelLogger;
103
+ // --- test seams (production uses the real signal-cli) ---
104
+ readonly sidecarFactory?: (opts: { account: string; binary: string }) => SignalSidecarLike;
105
+ readonly linkFactory?: (opts: { deviceName: string; binary: string }) => LinkProcessHandle;
106
+ readonly listAccountsFn?: (opts: { binary: string }) => Promise<string[]>;
107
+ readonly findBinaryFn?: () => string | null;
108
+ readonly attachmentsDir?: string;
109
+ readonly spawnFn?: SpawnFn;
110
+ }
111
+
112
+ export class SignalChannel implements Channel<SignalStartOpts> {
113
+ readonly name = 'signal';
114
+ /**
115
+ * Installed on the session by the CLI dispatcher. Replaced in `start()` once
116
+ * the live tool registry is available for `['*']` expansion; until then it
117
+ * denies everything (safe default before start).
118
+ */
119
+ permissionResolver: PermissionResolver;
120
+
121
+ private readonly opts: SignalChannelOptions;
122
+ private session: Session | null = null;
123
+ private model: string | undefined;
124
+ private account: string | null = null;
125
+ private allowedSenders = new Set<string>();
126
+
127
+ private sidecar: SignalSidecarLike | null = null;
128
+ private rpc: SignalRpcLike | null = null;
129
+ private link: LinkProcessHandle | null = null;
130
+
131
+ private handle: ChannelHandle | null = null;
132
+ private runningSettled = false;
133
+ private resolveRunning: (() => void) | null = null;
134
+ private rejectRunning: ((err: Error) => void) | null = null;
135
+ private stopping = false;
136
+
137
+ // Single-flight turn state + the bounded own-turn-id set the foreign-turn
138
+ // mirror gate filters on (invariant #8).
139
+ private readonly turns = new TurnCoordinator();
140
+ private lastTarget: SendTarget | null = null;
141
+ private logUnsub: (() => void) | null = null;
142
+
143
+ /**
144
+ * Timestamps of messages WE sent through the daemon (signal-cli's `send`
145
+ * result). A linked device sees the account's sends come back as
146
+ * `syncMessage.sentMessage` envelopes — including its own — so any sync'd
147
+ * sent-message whose timestamp is in this set is an echo of ourselves and
148
+ * must be dropped (loop protection).
149
+ */
150
+ private readonly sentTimestamps = new Set<number>();
151
+
152
+ // Link-window connect state, published via requestUrl/connected + the
153
+ // dedicated-runner status file (the desktop panel renders the QR from it).
154
+ private linkUri: string | null = null;
155
+ private linked = false;
156
+ private readonly connectListeners = new Set<() => void>();
157
+ private readonly linkedListeners = new Set<(account: string) => void>();
158
+
159
+ private lastDropWarnAt = 0;
160
+
161
+ constructor(opts: SignalChannelOptions) {
162
+ this.opts = opts;
163
+ // Pre-start: deny-all (replaced with the real allow-list resolver in start()).
164
+ this.permissionResolver = buildSignalPermissionResolver({
165
+ allowedTools: [],
166
+ allToolNames: [],
167
+ ...(opts.logger ? { logger: opts.logger } : {}),
168
+ });
169
+ }
170
+
171
+ /** The `sgnl://linkdevice…` URI while a linking window is open; null otherwise. */
172
+ get requestUrl(): string | null {
173
+ return this.linkUri;
174
+ }
175
+
176
+ /** Whether the account is linked (the "connect the other side" step). */
177
+ get connected(): boolean {
178
+ return this.linked;
179
+ }
180
+
181
+ /** Fires when linking completes (the pair flow prints success off this). */
182
+ onLinked(listener: (account: string) => void): () => void {
183
+ this.linkedListeners.add(listener);
184
+ return () => this.linkedListeners.delete(listener);
185
+ }
186
+
187
+ async start(startOpts: SignalStartOpts): Promise<ChannelHandle> {
188
+ if (this.handle) return this.handle;
189
+ this.session = startOpts.session;
190
+ this.model = startOpts.model;
191
+
192
+ const findBinary = this.opts.findBinaryFn ?? (() => findSignalCliOnPath());
193
+ const binary = this.opts.binary ?? findBinary();
194
+ if (!binary) throw new Error(SIGNAL_CLI_INSTALL_HINT);
195
+
196
+ // Account: explicit option → env → vault (shared channel-kit precedence).
197
+ this.account =
198
+ this.opts.account ??
199
+ (await resolveSecret(this.opts.vault, {
200
+ envVar: SIGNAL_ACCOUNT_ENV,
201
+ vaultKey: SIGNAL_ACCOUNT_KEY,
202
+ }));
203
+
204
+ // Sender allow-list: vault + option extras. The owner's own Note-to-Self is
205
+ // allowed implicitly (see handleSyncSent) and needs no entry.
206
+ this.allowedSenders = new Set(
207
+ [
208
+ ...parseAllowedSenders(await this.opts.vault.get(SIGNAL_ALLOWED_SENDERS_KEY)),
209
+ ...(this.opts.allowedSenders ?? []).map(normalizeSender),
210
+ ].filter(Boolean),
211
+ );
212
+
213
+ // Linked yet? (One-shot `listAccounts` spawn — NOT in isAvailable, JVM
214
+ // spawns are too slow for discovery.) A probe failure is treated as
215
+ // "assume linked" when an account is configured: the daemon boot below
216
+ // surfaces the real error with signal-cli's own stderr, which beats
217
+ // guessing wrong and opening a spurious link window.
218
+ let isLinked = false;
219
+ if (this.account) {
220
+ try {
221
+ const listAccounts = this.opts.listAccountsFn ?? ((o: { binary: string }) => listSignalAccounts(o));
222
+ const accounts = await listAccounts({ binary });
223
+ isLinked = accounts.includes(this.account);
224
+ } catch (err) {
225
+ this.opts.logger?.warn?.('signal: listAccounts probe failed; assuming linked', {
226
+ err: err instanceof Error ? err.message : String(err),
227
+ });
228
+ isLinked = true;
229
+ }
230
+ }
231
+
232
+ // Swap in the real autonomous allow-list resolver now that the tool
233
+ // registry is live (mirrors the Slack channel; `['*']` expands here).
234
+ const allowedTools = startOpts.allowedTools ?? this.opts.allowedTools ?? [];
235
+ const allToolNames = this.session.tools.list().map((t) => t.name);
236
+ this.permissionResolver = buildSignalPermissionResolver({
237
+ allowedTools,
238
+ allToolNames,
239
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
240
+ });
241
+ this.session.setPermissionResolver(this.permissionResolver);
242
+
243
+ // Mirror-to-last-target: post assistant prose for turns this channel did
244
+ // NOT initiate (a co-attached surface ran one) into the last thread served.
245
+ this.logUnsub = this.session.log.subscribe((event) => this.mirrorForeignTurn(event));
246
+
247
+ const running = new Promise<void>((resolve, reject) => {
248
+ this.resolveRunning = resolve;
249
+ this.rejectRunning = reject;
250
+ });
251
+
252
+ const dedicated = startOpts.dedicated === true || process.env.MOXXY_DEDICATED_RUNNER === '1';
253
+ if (isLinked) {
254
+ this.linked = true;
255
+ await this.bootDaemon(binary, this.account!);
256
+ } else if (startOpts.pair || dedicated) {
257
+ await this.openLinkWindow(binary);
258
+ } else {
259
+ this.logUnsub?.();
260
+ this.logUnsub = null;
261
+ throw new Error(
262
+ 'This machine is not linked to a Signal account yet. Run `moxxy channels signal pair` ' +
263
+ '(scan the QR with your phone: Signal → Settings → Linked Devices), or start it from the desktop Channels panel.',
264
+ );
265
+ }
266
+
267
+ this.handle = {
268
+ running,
269
+ onConnectChange: (listener) => {
270
+ this.connectListeners.add(listener);
271
+ return () => this.connectListeners.delete(listener);
272
+ },
273
+ stop: async (reason = 'shutdown') => {
274
+ this.stopping = true;
275
+ // Abort the in-flight turn FIRST so the model loop stops the moment the
276
+ // operator asks to shut down (shared/remote Session: spend continues
277
+ // otherwise and only its output is discarded).
278
+ this.turns.abort(reason);
279
+ this.link?.cancel();
280
+ this.link = null;
281
+ this.logUnsub?.();
282
+ this.logUnsub = null;
283
+ const sidecar = this.sidecar;
284
+ this.sidecar = null;
285
+ this.rpc = null;
286
+ if (sidecar) await sidecar.stop();
287
+ this.settleRunning();
288
+ },
289
+ };
290
+ this.opts.logger?.info?.('signal: channel started', {
291
+ linked: this.linked,
292
+ account: this.account,
293
+ allowedSenders: this.allowedSenders.size,
294
+ });
295
+ return this.handle;
296
+ }
297
+
298
+ // -------------------------------------------------------------------------
299
+ // Linking (secondary-device pairing)
300
+ // -------------------------------------------------------------------------
301
+
302
+ private async openLinkWindow(binary: string): Promise<void> {
303
+ const makeLink =
304
+ this.opts.linkFactory ??
305
+ ((o: { deviceName: string; binary: string }) =>
306
+ startLinkProcess({
307
+ deviceName: o.deviceName,
308
+ binary: o.binary,
309
+ ...(this.opts.spawnFn ? { spawnFn: this.opts.spawnFn } : {}),
310
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
311
+ }));
312
+ const link = makeLink({ deviceName: LINK_DEVICE_NAME, binary });
313
+ this.link = link;
314
+ this.linkUri = await link.uri; // throws when signal-cli can't start linking
315
+ this.notifyConnectChange();
316
+ this.opts.logger?.info?.('signal: linking window open');
317
+
318
+ // Completion is async (the user scans on their phone). Boot the daemon the
319
+ // moment linking lands; a failure at any point is fatal for the handle so
320
+ // a supervisor restarts us (and the pair flow surfaces the error).
321
+ void link.completed
322
+ .then(async ({ account }) => {
323
+ if (this.stopping) return;
324
+ const acct = account ?? this.account;
325
+ if (!acct) {
326
+ throw new Error(
327
+ 'linking completed but signal-cli did not report the account number — ' +
328
+ `store it via \`moxxy channels signal setup\` (vault key ${SIGNAL_ACCOUNT_KEY}) and restart`,
329
+ );
330
+ }
331
+ this.account = acct;
332
+ await this.opts.vault.set(SIGNAL_ACCOUNT_KEY, acct, ['signal']);
333
+ this.link = null;
334
+ this.linkUri = null;
335
+ this.linked = true;
336
+ await this.bootDaemon(binary, acct);
337
+ this.notifyConnectChange();
338
+ for (const listener of this.linkedListeners) {
339
+ try {
340
+ listener(acct);
341
+ } catch {
342
+ /* listener errors are not ours */
343
+ }
344
+ }
345
+ this.opts.logger?.info?.('signal: linked', { account: acct });
346
+ })
347
+ .catch((err: unknown) => {
348
+ if (this.stopping) return;
349
+ this.fatal(
350
+ new Error(`Signal linking failed: ${err instanceof Error ? err.message : String(err)}`),
351
+ );
352
+ });
353
+ }
354
+
355
+ // -------------------------------------------------------------------------
356
+ // Daemon + inbound envelopes
357
+ // -------------------------------------------------------------------------
358
+
359
+ private async bootDaemon(binary: string, account: string): Promise<void> {
360
+ const makeSidecar =
361
+ this.opts.sidecarFactory ??
362
+ ((o: { account: string; binary: string }) =>
363
+ new SignalSidecar({
364
+ account: o.account,
365
+ binary: o.binary,
366
+ ...(this.opts.spawnFn ? { spawnFn: this.opts.spawnFn } : {}),
367
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
368
+ }));
369
+ const sidecar = makeSidecar({ account, binary });
370
+ this.sidecar = sidecar;
371
+ const rpc = await sidecar.start();
372
+ this.rpc = rpc;
373
+ rpc.onNotification('receive', (params) => this.handleReceive(params));
374
+ // An unexpected daemon death is fatal: reject `running` so the process
375
+ // exits non-zero and a supervisor (desktop panel / systemd) restarts it.
376
+ sidecar.onExit((code) => {
377
+ if (this.stopping) return;
378
+ this.fatal(new Error(`signal-cli daemon exited unexpectedly (code=${code ?? 'null'})`));
379
+ });
380
+ }
381
+
382
+ /** Every inbound notification funnels through here — zod-validate FIRST. */
383
+ private handleReceive(params: unknown): void {
384
+ const parsed = receiveParamsSchema.safeParse(params);
385
+ if (!parsed.success) {
386
+ this.warnDrop('invalid receive payload (schema mismatch)');
387
+ return;
388
+ }
389
+ const envelope = parsed.data.envelope;
390
+ if (envelope.dataMessage) {
391
+ this.handleDataMessage(envelope);
392
+ return;
393
+ }
394
+ const sent = envelope.syncMessage?.sentMessage;
395
+ if (sent) {
396
+ this.handleSyncSent(sent);
397
+ return;
398
+ }
399
+ // Typing / receipt / group-update envelopes: nothing to do.
400
+ }
401
+
402
+ /** A message from ANOTHER account — gate on the sender allow-list. */
403
+ private handleDataMessage(envelope: SignalEnvelope): void {
404
+ const data = envelope.dataMessage!;
405
+ const number = envelope.sourceNumber ?? null;
406
+ const uuid = envelope.sourceUuid ?? null;
407
+ const senderIds = [number, uuid, envelope.source ?? null]
408
+ .filter((x): x is string => typeof x === 'string' && x.length > 0)
409
+ .map(normalizeSender);
410
+ if (senderIds.length === 0) {
411
+ this.warnDrop('data message without a sender identity');
412
+ return;
413
+ }
414
+ // v1: direct messages only. Group fan-in needs its own trust story.
415
+ if (data.groupInfo) {
416
+ this.opts.logger?.debug?.('signal: ignoring group message');
417
+ return;
418
+ }
419
+ // Our own account as a dataMessage source is an echo path (legit owner
420
+ // prompts arrive as syncMessage.sentMessage) — never respond to ourselves.
421
+ if (this.account && senderIds.includes(normalizeSender(this.account))) {
422
+ this.opts.logger?.debug?.('signal: ignoring data message from own account');
423
+ return;
424
+ }
425
+ // THE allow-list gate: every session-reaching path passes through here or
426
+ // through handleSyncSent's owner check. Unknown senders are dropped
427
+ // silently (no reply) — answering would leak the bot's existence.
428
+ if (!senderIds.some((id) => this.allowedSenders.has(id))) {
429
+ this.warnDrop(`unauthorized sender (${senderIds[0] ?? 'unknown'})`);
430
+ return;
431
+ }
432
+ const target: SendTarget = { recipient: number ?? uuid ?? senderIds[0]! };
433
+ this.dispatchInBackground(
434
+ this.processMessage(target, data.message ?? null, data.attachments),
435
+ 'data-message',
436
+ );
437
+ }
438
+
439
+ /**
440
+ * A sync'd copy of a message the ACCOUNT OWNER sent from another device.
441
+ * Two jobs: (1) drop echoes of OUR OWN sends (linked devices receive the
442
+ * account's sends back — loop protection), (2) accept the owner's
443
+ * Note-to-Self prompts, which are allowed by default after linking.
444
+ */
445
+ private handleSyncSent(sent: NonNullable<NonNullable<SignalEnvelope['syncMessage']>['sentMessage']>): void {
446
+ if (typeof sent.timestamp === 'number' && this.sentTimestamps.has(sent.timestamp)) {
447
+ this.opts.logger?.debug?.('signal: dropping sync echo of own send');
448
+ return;
449
+ }
450
+ if (sent.groupInfo) return;
451
+ // Only the owner's own Note-to-Self drives the session. Their outbound
452
+ // messages to OTHER people are private conversation traffic — never react.
453
+ const dest = sent.destinationNumber ?? sent.destination ?? null;
454
+ const isNoteToSelf = this.account != null && dest != null && dest === this.account;
455
+ if (!isNoteToSelf) return;
456
+ this.dispatchInBackground(
457
+ this.processMessage({ noteToSelf: true }, sent.message ?? null, sent.attachments),
458
+ 'note-to-self',
459
+ );
460
+ }
461
+
462
+ /** Voice → transcript, busy gate, then one coordinated turn. */
463
+ private async processMessage(
464
+ target: SendTarget,
465
+ text: string | null,
466
+ attachments: ReadonlyArray<SignalAttachment> | undefined,
467
+ ): Promise<void> {
468
+ if (!this.session) return;
469
+ let prompt = text?.trim() ?? '';
470
+ if (!prompt) {
471
+ const audio = pickAudioAttachment(attachments);
472
+ if (!audio) return; // stickers/images/reactions — nothing to run
473
+ const transcript = await transcribeVoiceAttachment(
474
+ {
475
+ session: this.session,
476
+ attachmentsDir: this.opts.attachmentsDir ?? signalCliAttachmentsDir(),
477
+ reply: (t) => this.sendText(target, t),
478
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
479
+ },
480
+ audio,
481
+ );
482
+ if (!transcript) return;
483
+ prompt = transcript;
484
+ }
485
+
486
+ // Atomic single-flight guard (the coordinator claims the slot BEFORE any
487
+ // await); the turnId minted here is recorded as an own-turn id — that's
488
+ // what mirrorForeignTurn filters on (invariant #8).
489
+ const lease = this.turns.begin(newTurnId());
490
+ if (!lease) {
491
+ await this.sendText(target, 'I am still working on the previous prompt. One moment…');
492
+ return;
493
+ }
494
+ this.lastTarget = target;
495
+ try {
496
+ await runSignalTurn(
497
+ {
498
+ session: this.session,
499
+ send: (t) => this.sendText(target, t),
500
+ sendTyping: (stop) => this.sendTyping(target, stop),
501
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
502
+ },
503
+ {
504
+ text: prompt,
505
+ ...(this.model ? { model: this.model } : {}),
506
+ controller: lease.controller,
507
+ turnId: lease.turnId,
508
+ },
509
+ );
510
+ } finally {
511
+ lease.end();
512
+ }
513
+ }
514
+
515
+ // -------------------------------------------------------------------------
516
+ // Outbound
517
+ // -------------------------------------------------------------------------
518
+
519
+ /** Send one message and record its timestamp for the sync-echo drop. */
520
+ private async sendText(target: SendTarget, text: string): Promise<void> {
521
+ const rpc = this.rpc;
522
+ if (!rpc) throw new Error('signal daemon is not running');
523
+ const params: Record<string, unknown> =
524
+ 'noteToSelf' in target ? { noteToSelf: true, message: text } : { recipient: [target.recipient], message: text };
525
+ const result = (await rpc.request('send', params)) as { timestamp?: unknown } | null | undefined;
526
+ const ts = result && typeof result === 'object' ? result.timestamp : undefined;
527
+ if (typeof ts === 'number') {
528
+ this.sentTimestamps.add(ts);
529
+ if (this.sentTimestamps.size > MAX_SENT_TIMESTAMPS) {
530
+ const oldest = this.sentTimestamps.values().next().value;
531
+ if (oldest !== undefined) this.sentTimestamps.delete(oldest);
532
+ }
533
+ }
534
+ }
535
+
536
+ private async sendTyping(target: SendTarget, stop: boolean): Promise<void> {
537
+ const rpc = this.rpc;
538
+ if (!rpc) return;
539
+ const recipient = 'noteToSelf' in target ? this.account : target.recipient;
540
+ if (!recipient) return;
541
+ await rpc.request('sendTyping', { recipient: [recipient], ...(stop ? { stop: true } : {}) });
542
+ }
543
+
544
+ /**
545
+ * Post the assistant's prose for a turn this channel did not initiate.
546
+ * Skipped for our own turnIds (robust to async ordering / replay, invariant
547
+ * #8) and while a turn of ours is streaming via the chunked sender.
548
+ */
549
+ private mirrorForeignTurn(event: MoxxyEvent): void {
550
+ const text = this.turns.mirrorText(event);
551
+ if (text == null) return;
552
+ const target = this.lastTarget;
553
+ if (!target || !this.rpc) return;
554
+ void (async () => {
555
+ for (const part of splitForSignal(text)) {
556
+ await this.sendText(target, part);
557
+ }
558
+ })().catch((err) => {
559
+ this.opts.logger?.warn?.('signal: mirror failed', { err: String(err) });
560
+ });
561
+ }
562
+
563
+ // -------------------------------------------------------------------------
564
+ // Plumbing
565
+ // -------------------------------------------------------------------------
566
+
567
+ /**
568
+ * Run a handler detached from the socket read loop (an envelope handler that
569
+ * awaited a whole turn would park notification delivery for its duration).
570
+ * Errors are logged here — nothing upstream awaits this promise.
571
+ */
572
+ private dispatchInBackground(work: Promise<void>, kind: string): void {
573
+ void work.catch((err) => {
574
+ this.opts.logger?.warn?.('signal: handler failed', { kind, err: String(err) });
575
+ });
576
+ }
577
+
578
+ private warnDrop(reason: string): void {
579
+ const now = Date.now();
580
+ if (now - this.lastDropWarnAt < DROP_WARN_INTERVAL_MS) return;
581
+ this.lastDropWarnAt = now;
582
+ this.opts.logger?.warn?.(`signal: dropped inbound envelope — ${reason}`);
583
+ }
584
+
585
+ private notifyConnectChange(): void {
586
+ for (const listener of this.connectListeners) {
587
+ try {
588
+ listener();
589
+ } catch (err) {
590
+ this.opts.logger?.warn?.('signal: connect-change listener threw', { err: String(err) });
591
+ }
592
+ }
593
+ }
594
+
595
+ private settleRunning(): void {
596
+ if (this.runningSettled) return;
597
+ this.runningSettled = true;
598
+ this.resolveRunning?.();
599
+ }
600
+
601
+ private fatal(err: Error): void {
602
+ this.opts.logger?.error?.('signal: fatal channel error', { err: err.message });
603
+ if (this.runningSettled) return;
604
+ this.runningSettled = true;
605
+ this.rejectRunning?.(err);
606
+ }
607
+ }