switchroom 0.19.48 → 0.20.1

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 (60) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +18 -1
  3. package/dist/auth-broker/index.js +19 -2
  4. package/dist/buzz-gateway/index.js +9367 -0
  5. package/dist/cli/notion-write-pretool.mjs +18 -1
  6. package/dist/cli/switchroom.js +24734 -16371
  7. package/dist/host-control/main.js +59 -9
  8. package/dist/vault/approvals/kernel-server.js +19 -2
  9. package/dist/vault/broker/server.js +19 -2
  10. package/package.json +6 -4
  11. package/profiles/_base/start.sh.hbs +148 -2
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/dev-protocol/SKILL.md +30 -1
  14. package/skills/switchroom-architecture/SKILL.md +5 -0
  15. package/skills/switchroom-cli/SKILL.md +1 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  17. package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
  18. package/telegram-plugin/dist/server.js +7 -4
  19. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  20. package/telegram-plugin/gateway/access-store.ts +194 -0
  21. package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
  22. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  23. package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
  24. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  25. package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
  26. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  27. package/telegram-plugin/gateway/channel-route.ts +272 -0
  28. package/telegram-plugin/gateway/gateway.ts +115 -203
  29. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  30. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  31. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  32. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  33. package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
  34. package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
  35. package/telegram-plugin/gateway/stream-render.ts +21 -0
  36. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  37. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  38. package/telegram-plugin/history.ts +15 -0
  39. package/telegram-plugin/llm-error-present.ts +9 -4
  40. package/telegram-plugin/model-unavailable.ts +4 -0
  41. package/telegram-plugin/operator-events.fixtures.json +12 -12
  42. package/telegram-plugin/operator-events.ts +81 -9
  43. package/telegram-plugin/session-tail.ts +7 -1
  44. package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
  45. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  46. package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
  47. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  48. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  49. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  50. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  51. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  52. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  53. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  54. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  55. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  56. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
  57. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  58. package/telegram-plugin/voice-normalize-text.ts +5 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  60. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Buzz co-channel — Phase 2b hub-side mirror module.
3
+ *
4
+ * This is the gateway-side half of "the part that actually sends". It sits
5
+ * strictly DOWNSTREAM of a successful Telegram delivery: `sendReply` calls
6
+ * `mirrorReplyDelivered(...)` only AFTER the Telegram copy has landed, and
7
+ * `executeEditMessage` calls `mirrorCorrection(...)` after a Telegram edit
8
+ * lands. Every Buzz publish is fire-and-forget — a failure or absence of the
9
+ * Buzz peer NEVER fails, delays, or retries the Telegram answer (the core
10
+ * invariant). There is no agent-facing Buzz-only send path: the ONLY way a
11
+ * Buzz event is ever emitted is as a mirror of an already-delivered Telegram
12
+ * message.
13
+ *
14
+ * Content signing lives in the sidecar (`src/buzz-gateway/publisher.ts`, the
15
+ * sole content-signer, S3). The hub only decides ROUTE + OWNER SAFETY and hands
16
+ * already-Telegram-scrubbed text (layer-1 redaction, via `normalizeOutboundBody`
17
+ * upstream) to the peer as an `outbound_to_buzz` request; the sidecar re-scrubs
18
+ * through `detectSecrets` (layer-2) before it ever reaches `finalizeEvent`.
19
+ *
20
+ * Dark by default: `getBuzzMirror()` returns null until `initBuzzMirror(...)`
21
+ * is called, which the gateway does ONLY when `channels.buzz.enabled === true`.
22
+ * With Buzz disabled the hook sites are `getBuzzMirror()?.…` — a byte-identical
23
+ * no-op on the hot path.
24
+ */
25
+
26
+ import { randomUUID } from "crypto";
27
+ import { join } from "path";
28
+ import type { OutboundToBuzzMessage } from "./ipc-protocol.js";
29
+ import {
30
+ resolveRoute,
31
+ isBuzzThreadedPublishSafe,
32
+ parseConfiguredMirrorMode,
33
+ type BuzzCoords,
34
+ type Channel,
35
+ } from "./channel-route.js";
36
+ import {
37
+ createCorrelationStore,
38
+ type CorrelationStore,
39
+ } from "./buzz-mirror-correlation-store.js";
40
+
41
+ export type BuzzPeerSender = (msg: OutboundToBuzzMessage) => boolean;
42
+
43
+ export interface BuzzMirrorConfig {
44
+ /** Configured mode, ALREADY narrowed to both|off (S2) by the caller. */
45
+ mode: "both" | "off";
46
+ /** This gateway's agent name — stamped on every outbound_to_buzz. */
47
+ agentName: string;
48
+ /**
49
+ * The relay-minted group UUID a TELEGRAM-origin answer is mirrored to as a
50
+ * fresh top-level post (`channels.buzz.default_channel_id`). Empty string ⇒
51
+ * telegram-origin mirroring is disabled (no channel to post into); buzz-origin
52
+ * threaded replies still work (they carry their own channelId).
53
+ */
54
+ defaultChannelId: string;
55
+ /**
56
+ * Absolute path to the durable msg→Buzz correlation journal (#4222). When
57
+ * set, the `${chatId}:${messageId}` → published-event map is persisted and
58
+ * reloaded on construction, so an `edit_message` correction survives a gateway
59
+ * restart. Omit (dev/one-shot/tests) to keep the map in-memory only — same
60
+ * bound, no durability. See `buzz-mirror-correlation-store.ts`.
61
+ */
62
+ correlationJournalPath?: string;
63
+ /** Optional log sink (defaults to a no-op). */
64
+ log?: (msg: string) => void;
65
+ }
66
+
67
+ export interface MirrorReplyInput {
68
+ /** The answer text AFTER layer-1 Telegram scrub (normalizeOutboundBody). */
69
+ scrubbedText: string;
70
+ /** Resolved reply-owner turn's origin channel. */
71
+ ownerOriginChannel: Channel;
72
+ /** Owner turn's Buzz coordinates, when it originated on Buzz. */
73
+ ownerBuzzCoords?: BuzzCoords;
74
+ /** True IFF the reply positively echoed the owner turn's id (S1). */
75
+ ownerEchoed: boolean;
76
+ /** True IFF a live/recent turn of a DIFFERENT origin exists (S1). */
77
+ hasRecentDifferentOriginTurn: boolean;
78
+ /**
79
+ * `${chatId}:${messageId}` keys of the Telegram messages this answer was
80
+ * delivered as. Recorded so a later `edit_message` on any of them can find
81
+ * the published Buzz event to correct.
82
+ */
83
+ telegramMessageKeys: string[];
84
+ /**
85
+ * `${chatId}:${messageId}` of the Telegram message this outbound answer is
86
+ * itself a reply to (its Telegram `reply_to` antecedent), when it has one.
87
+ * Used ONLY on the telegram-origin path to thread the mirrored Buzz event
88
+ * under the antecedent's previously-published Buzz event (NIP-10 outbound
89
+ * continuity). When the antecedent was never mirrored (e.g. it is a user's
90
+ * inbound message, or it aged past the correlation bound) the lookup misses
91
+ * and the mirror stays a flat top-level post — no wrong/guessed tag. Absent
92
+ * for a non-reply answer. Ignored on the buzz-origin path (that thread is
93
+ * bound by `ownerBuzzCoords`, not a Telegram antecedent).
94
+ */
95
+ antecedentTelegramMessageKey?: string;
96
+ /**
97
+ * True IFF `antecedentTelegramMessageKey` is the quote-opt-in DEFAULT — i.e.
98
+ * the caller had no explicit/model-supplied `reply_to` and defaulted it to the
99
+ * latest INBOUND user message (#4301). That message is never in the
100
+ * correlation store, so its lookup ALWAYS misses; distinguishing it lets the
101
+ * mirror log the expected flat fallback quietly with a distinct reason instead
102
+ * of as an "outbound thread MISS", so a genuine eviction miss stays visible.
103
+ */
104
+ antecedentIsQuoteOptInDefault?: boolean;
105
+ }
106
+
107
+ export interface MirrorCorrectionInput {
108
+ /** `${chatId}:${messageId}` of the edited Telegram message. */
109
+ telegramMessageKey: string;
110
+ /** The edit text AFTER layer-1 Telegram scrub. */
111
+ scrubbedText: string;
112
+ }
113
+
114
+ /** F6 — coalesce a burst of edits into a single correction event. */
115
+ export const CORRECTION_DEBOUNCE_MS = 30_000;
116
+
117
+ /** Bound the in-memory correlation / message maps (FIFO eviction). */
118
+ const MAX_TRACKED = 4096;
119
+
120
+ interface PendingPublish {
121
+ channelId: string;
122
+ telegramMessageKeys: string[];
123
+ /**
124
+ * The NIP-10 thread root this in-flight event belongs to, when it threaded
125
+ * under a parent. Undefined for a fresh top-level post — in which case the
126
+ * event's OWN id (learned in `onPublishResult`) becomes the thread root. Stored
127
+ * so the correlation record carries the root a LATER reply's `root` marker
128
+ * needs.
129
+ */
130
+ threadRootId?: string;
131
+ }
132
+
133
+ class BuzzMirror {
134
+ private readonly cfg: BuzzMirrorConfig;
135
+ private readonly log: (msg: string) => void;
136
+ private sender: BuzzPeerSender | null = null;
137
+
138
+ /** correlationId → in-flight publish awaiting its buzz_publish_result. */
139
+ private readonly pending = new Map<string, PendingPublish>();
140
+ private readonly pendingOrder: string[] = [];
141
+
142
+ /**
143
+ * `${chatId}:${messageId}` → the published Buzz event it maps to. Durable
144
+ * (JSONL journal) when `correlationJournalPath` is configured, so a correction
145
+ * survives a gateway restart (#4222); the store enforces the same MAX_TRACKED
146
+ * FIFO bound in memory and on disk.
147
+ */
148
+ private readonly msgToBuzz: CorrelationStore;
149
+
150
+ /**
151
+ * Count of `mirrorCorrection` calls whose key was genuinely absent from the
152
+ * correlation store (never mirrored, or evicted past the bound) — surfaced for
153
+ * the loud-miss regression assertion, not just the log line.
154
+ */
155
+ private correctionMisses = 0;
156
+
157
+ /** `${chatId}:${messageId}` → live correction debounce timer. */
158
+ private readonly correctionTimers = new Map<string, ReturnType<typeof setTimeout>>();
159
+
160
+ constructor(cfg: BuzzMirrorConfig) {
161
+ this.cfg = cfg;
162
+ this.log = cfg.log ?? (() => {});
163
+ this.msgToBuzz = createCorrelationStore({
164
+ journalPath: cfg.correlationJournalPath,
165
+ capacity: MAX_TRACKED,
166
+ log: this.log,
167
+ });
168
+ }
169
+
170
+ /** Test/introspection: number of corrections that missed the correlation store. */
171
+ getCorrectionMisses(): number {
172
+ return this.correctionMisses;
173
+ }
174
+
175
+ /** Register the transport to the duplex Buzz peer (ipcServer.sendToBuzzPeer). */
176
+ attachSender(sender: BuzzPeerSender): void {
177
+ this.sender = sender;
178
+ }
179
+
180
+ /** Release the correlation journal fd and cancel any pending correction timers. */
181
+ close(): void {
182
+ for (const timer of this.correctionTimers.values()) clearTimeout(timer);
183
+ this.correctionTimers.clear();
184
+ this.msgToBuzz.close();
185
+ }
186
+
187
+ private evict<T>(map: Map<string, T>, order: string[]): void {
188
+ while (order.length > MAX_TRACKED) {
189
+ const k = order.shift();
190
+ if (k !== undefined) map.delete(k);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Mirror a just-delivered Telegram answer to Buzz, if the route calls for it
196
+ * and the owner binding is safe (S1). No-op when Buzz is not in the route.
197
+ * Never throws — a mirror failure must never disturb the Telegram answer.
198
+ */
199
+ mirrorReplyDelivered(input: MirrorReplyInput): void {
200
+ try {
201
+ // buzzEnabled is implied — this instance only exists when enabled.
202
+ const route = resolveRoute(input.ownerOriginChannel, this.cfg.mode, true);
203
+ const buzzInRoute =
204
+ route.primary === "buzz" || route.mirrors.includes("buzz");
205
+ if (!buzzInRoute) return;
206
+
207
+ let channelId: string;
208
+ let replyToEventId: string | undefined;
209
+ let threadRootId: string | undefined;
210
+
211
+ if (input.ownerOriginChannel === "buzz" && input.ownerBuzzCoords) {
212
+ // THREADED reply into an existing Buzz conversation — the ONLY path the
213
+ // S1 owner guard gates. Fail safe to Telegram-only on an ambiguous bind.
214
+ if (
215
+ !isBuzzThreadedPublishSafe({
216
+ ownerEchoed: input.ownerEchoed,
217
+ hasRecentDifferentOriginTurn: input.hasRecentDifferentOriginTurn,
218
+ })
219
+ ) {
220
+ this.log(
221
+ "buzz-mirror: S1 guard blocked a threaded publish on an ambiguous " +
222
+ "owner binding (un-echoed reply + a recent different-origin turn) " +
223
+ "— delivered Telegram-only",
224
+ );
225
+ return;
226
+ }
227
+ channelId = input.ownerBuzzCoords.channelId;
228
+ replyToEventId = input.ownerBuzzCoords.eventId;
229
+ threadRootId = input.ownerBuzzCoords.threadRoot;
230
+ } else {
231
+ // TELEGRAM-origin → post to the configured channel. Not an owner-bound
232
+ // thread, so the S1 guard does not apply (design §3.3).
233
+ if (!this.cfg.defaultChannelId) return; // no channel to post into
234
+ channelId = this.cfg.defaultChannelId;
235
+
236
+ // NIP-10 OUTBOUND thread continuity: if this answer is itself a reply to
237
+ // a Telegram message that was ALREADY mirrored to Buzz, thread the new
238
+ // event under that antecedent's published event — a `reply` marker for
239
+ // the immediate parent and a `root` marker for the thread root — so a
240
+ // Telegram-origin reply chain renders threaded on the Buzz desktop
241
+ // instead of flat. The antecedent→event resolution reuses the SAME
242
+ // durable msg→event correlation store (#4280) the correction path uses;
243
+ // no new lookup surface. A MISS (antecedent never mirrored — e.g. it is
244
+ // the user's own inbound message — or evicted past MAX_TRACKED) leaves
245
+ // the post flat rather than emitting a wrong/guessed tag.
246
+ if (input.antecedentTelegramMessageKey) {
247
+ const parent = this.msgToBuzz.get(input.antecedentTelegramMessageKey);
248
+ if (parent && parent.channelId === channelId) {
249
+ replyToEventId = parent.eventId; // immediate parent → NIP-10 `reply`
250
+ // Thread root → NIP-10 `root`. Fall back to the parent's own id when
251
+ // the parent has no recorded root (it was itself top-level, or was
252
+ // journaled before threadRoot was tracked): then parent IS the root.
253
+ threadRootId = parent.threadRoot ?? parent.eventId;
254
+ } else if (parent) {
255
+ // #4299 CROSS-CHANNEL GUARD: the antecedent WAS mirrored, but into a
256
+ // DIFFERENT Buzz channel than this event's target (e.g. it was
257
+ // recorded via a buzz-origin threaded reply whose channelId came from
258
+ // the inbound event's own `h`-tag). This event publishes into
259
+ // `defaultChannelId`; threading it under a foreign-channel parent
260
+ // would carry e-tags that point into another group. Mirror FLAT
261
+ // instead — same fallback as a MISS, no cross-group e-tag.
262
+ this.log(
263
+ `buzz-mirror: outbound thread CROSS-CHANNEL — antecedent ` +
264
+ `${input.antecedentTelegramMessageKey} was mirrored into channel ` +
265
+ `${parent.channelId} != target ${channelId}; mirroring flat ` +
266
+ `(no cross-group e-tag)`,
267
+ );
268
+ } else if (input.antecedentIsQuoteOptInDefault) {
269
+ // #4301: quote-opt-in defaulted `reply_to` to the latest INBOUND user
270
+ // message, which is never in the correlation store — so this "miss"
271
+ // is EXPECTED, not an eviction. Log it quietly with a distinct reason
272
+ // (no "MISS") so genuine eviction misses stay visible in the logs.
273
+ this.log(
274
+ `buzz-mirror: outbound thread default-quote — antecedent ` +
275
+ `${input.antecedentTelegramMessageKey} is the latest inbound user ` +
276
+ `message (never mirrored); mirroring flat (expected, not an eviction)`,
277
+ );
278
+ } else {
279
+ this.log(
280
+ `buzz-mirror: outbound thread MISS — no Buzz correlation for ` +
281
+ `Telegram antecedent ${input.antecedentTelegramMessageKey}; ` +
282
+ `mirroring flat (never mirrored, or evicted past the bound)`,
283
+ );
284
+ }
285
+ }
286
+ }
287
+
288
+ this.publish(
289
+ {
290
+ channelId,
291
+ replyToEventId,
292
+ threadRootId,
293
+ payload: { kind: "message", text: input.scrubbedText },
294
+ },
295
+ input.telegramMessageKeys,
296
+ );
297
+ } catch (err) {
298
+ this.log(`buzz-mirror: mirrorReplyDelivered threw (ignored): ${String(err)}`);
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Debounced correction: an `edit_message` on a Telegram message that was
304
+ * mirrored to Buzz publishes a superseding `correction` event 30s after the
305
+ * last edit (F6 CORRECTION_DEBOUNCE_MS). No-op when the edited message was
306
+ * never mirrored (no Buzz event to correct). Never throws.
307
+ */
308
+ mirrorCorrection(input: MirrorCorrectionInput): void {
309
+ try {
310
+ const target = this.msgToBuzz.get(input.telegramMessageKey);
311
+ if (!target) {
312
+ // No mapping for this key — either it was genuinely never mirrored, or
313
+ // it aged out past MAX_TRACKED (memory AND journal). Either way the
314
+ // correction cannot land, so DON'T fail silently: emit a loud log and
315
+ // bump the miss counter so the gap is observable (#4222, audit "at
316
+ // minimum" ask). Pre-#4222 a restart also landed here (empty map) — the
317
+ // durable journal is what keeps that from being the common case.
318
+ this.correctionMisses++;
319
+ this.log(
320
+ `buzz-mirror: CORRECTION MISS — no Buzz correlation for Telegram ` +
321
+ `message ${input.telegramMessageKey}; the edit could NOT be mirrored ` +
322
+ `(never mirrored, or evicted past MAX_TRACKED=${MAX_TRACKED}). ` +
323
+ `Buzz copy may be stale. total_misses=${this.correctionMisses}`,
324
+ );
325
+ return;
326
+ }
327
+
328
+ const existing = this.correctionTimers.get(input.telegramMessageKey);
329
+ if (existing) clearTimeout(existing);
330
+
331
+ const timer = setTimeout(() => {
332
+ this.correctionTimers.delete(input.telegramMessageKey);
333
+ // Re-read: the mapping may have advanced (a later publish), but the
334
+ // targetEventId to supersede is the one current at fire time.
335
+ const t = this.msgToBuzz.get(input.telegramMessageKey);
336
+ if (!t) return;
337
+ this.publish(
338
+ {
339
+ channelId: t.channelId,
340
+ replyToEventId: t.eventId,
341
+ threadRootId: t.eventId,
342
+ payload: {
343
+ kind: "correction",
344
+ text: input.scrubbedText,
345
+ targetEventId: t.eventId,
346
+ },
347
+ },
348
+ [], // a correction is not itself re-correctable via a Telegram edit
349
+ );
350
+ }, CORRECTION_DEBOUNCE_MS);
351
+ if (typeof (timer as { unref?: () => void }).unref === "function") {
352
+ (timer as { unref: () => void }).unref();
353
+ }
354
+ this.correctionTimers.set(input.telegramMessageKey, timer);
355
+ } catch (err) {
356
+ this.log(`buzz-mirror: mirrorCorrection threw (ignored): ${String(err)}`);
357
+ }
358
+ }
359
+
360
+ /** Handle the sidecar's advisory publish outcome (buzz_publish_result). */
361
+ onPublishResult(msg: {
362
+ correlationId: string;
363
+ ok: boolean;
364
+ eventId?: string;
365
+ error?: string;
366
+ }): void {
367
+ const p = this.pending.get(msg.correlationId);
368
+ this.pending.delete(msg.correlationId);
369
+ if (!p) return;
370
+ if (!msg.ok || !msg.eventId) {
371
+ this.log(
372
+ `buzz-mirror: publish failed (correlationId=${msg.correlationId.slice(0, 8)}` +
373
+ `${msg.error ? ` error=${msg.error}` : ""}) — Telegram copy already delivered`,
374
+ );
375
+ return;
376
+ }
377
+ // Record the published event against each Telegram message it mirrored, so
378
+ // a later edit_message on any of them can target it for a correction AND a
379
+ // later reply whose antecedent is one of these messages can thread under it.
380
+ // The store persists (durable journal) + enforces the MAX_TRACKED FIFO bound.
381
+ //
382
+ // threadRoot: the root this event belongs to. If it threaded under a parent
383
+ // (`p.threadRootId` set) that parent's root IS this event's root; otherwise
384
+ // this is a fresh top-level post and its OWN id is the thread root.
385
+ const threadRoot = p.threadRootId ?? msg.eventId;
386
+ for (const key of p.telegramMessageKeys) {
387
+ this.msgToBuzz.set(key, { eventId: msg.eventId, channelId: p.channelId, threadRoot });
388
+ }
389
+ }
390
+
391
+ private publish(
392
+ fields: Omit<OutboundToBuzzMessage, "type" | "correlationId" | "agentName">,
393
+ telegramMessageKeys: string[],
394
+ ): void {
395
+ if (!this.sender) {
396
+ this.log("buzz-mirror: no Buzz peer connected — mirror dropped (Telegram copy delivered)");
397
+ return;
398
+ }
399
+ const correlationId = randomUUID();
400
+ const msg: OutboundToBuzzMessage = {
401
+ type: "outbound_to_buzz",
402
+ correlationId,
403
+ agentName: this.cfg.agentName,
404
+ ...fields,
405
+ };
406
+ const sent = this.sender(msg);
407
+ if (!sent) {
408
+ this.log("buzz-mirror: Buzz peer send returned false — mirror dropped (Telegram copy delivered)");
409
+ return;
410
+ }
411
+ this.pending.set(correlationId, {
412
+ channelId: fields.channelId,
413
+ telegramMessageKeys,
414
+ threadRootId: fields.threadRootId,
415
+ });
416
+ this.pendingOrder.push(correlationId);
417
+ this.evict(this.pending, this.pendingOrder);
418
+ }
419
+ }
420
+
421
+ let singleton: BuzzMirror | null = null;
422
+
423
+ /**
424
+ * Initialize the hub mirror. Called by the gateway ONLY when
425
+ * `channels.buzz.enabled === true`. Idempotent-ish: a second call replaces the
426
+ * instance (used by tests). When never called, `getBuzzMirror()` stays null and
427
+ * every hook site is a no-op.
428
+ */
429
+ export function initBuzzMirror(cfg: BuzzMirrorConfig): BuzzMirror {
430
+ singleton?.close(); // release any prior instance's journal fd + timers
431
+ singleton = new BuzzMirror(cfg);
432
+ return singleton;
433
+ }
434
+
435
+ export function getBuzzMirror(): BuzzMirror | null {
436
+ return singleton;
437
+ }
438
+
439
+ /**
440
+ * Resolve the durable msg→Buzz correlation journal path from env (#4222). Lives
441
+ * under the same `$TELEGRAM_STATE_DIR/buzz/` dir as the sidecar's dedup journal
442
+ * but at a DISTINCT filename — a collision on `journal.jsonl` would corrupt
443
+ * both. `BUZZ_MIRROR_CORRELATION_PATH` overrides. When `TELEGRAM_STATE_DIR` is
444
+ * unset (dev/one-shot), returns undefined so the store degrades to in-memory
445
+ * only rather than scattering a journal under the home dir.
446
+ */
447
+ export function resolveCorrelationJournalPath(
448
+ env: Record<string, string | undefined> = process.env,
449
+ ): string | undefined {
450
+ const override = env.BUZZ_MIRROR_CORRELATION_PATH?.trim();
451
+ if (override) return override;
452
+ const stateDir = env.TELEGRAM_STATE_DIR?.trim();
453
+ if (!stateDir) return undefined;
454
+ return join(stateDir, "buzz", "mirror-correlation.jsonl");
455
+ }
456
+
457
+ /**
458
+ * Boot the hub mirror from env at gateway startup — the single wiring seam the
459
+ * gateway calls. DARK BY DEFAULT and by construction: returns null (leaving
460
+ * `getBuzzMirror()` null, every hook site a no-op) unless BOTH hold —
461
+ * (1) `BUZZ_ENABLED` is truthy, AND
462
+ * (2) the S2-narrowed mode (`parseConfiguredMirrorMode`) is `both`;
463
+ * a configured `origin`/`off` degrades to dark, never a half-live mirror.
464
+ * The Buzz env vars are projected at compose time from `channels.buzz`
465
+ * (src/agents/compose.ts), with BUZZ_ENABLED=1 gated on `enabled === true`;
466
+ * an enabled:false/absent block leaves them unset, so for those agents this
467
+ * is inert by construction. `sender` is the transport to the duplex peer
468
+ * (`ipcServer.sendToBuzzPeer`). Returns the booted instance for tests.
469
+ */
470
+ export function maybeBootBuzzMirror(
471
+ sender: BuzzPeerSender,
472
+ env: Record<string, string | undefined> = process.env,
473
+ ): BuzzMirror | null {
474
+ if (env.BUZZ_ENABLED !== "1" && env.BUZZ_ENABLED !== "true") return null;
475
+ const mode = parseConfiguredMirrorMode(env.BUZZ_MIRROR);
476
+ if (mode !== "both") return null;
477
+ const bm = initBuzzMirror({
478
+ mode,
479
+ agentName: env.SWITCHROOM_AGENT_NAME?.trim() ?? "",
480
+ defaultChannelId: env.BUZZ_CHANNEL_IDS?.trim() ?? "",
481
+ correlationJournalPath: resolveCorrelationJournalPath(env),
482
+ log: (m) => process.stderr.write(`telegram gateway: buzz-mirror — ${m}\n`),
483
+ });
484
+ bm.attachSender(sender);
485
+ return bm;
486
+ }
487
+
488
+ /** Test-only: tear down the singleton so cases don't leak state into each other. */
489
+ export function __resetBuzzMirrorForTests(): void {
490
+ singleton?.close();
491
+ singleton = null;
492
+ }
493
+
494
+ export type { BuzzMirror };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Buzz co-channel — compile-time type-identity guards (2a MINOR-3).
3
+ *
4
+ * `gateway.ts` INLINES the `CurrentTurn.buzzCoords` shape
5
+ * (`{ channelId; eventId; threadRoot }`) rather than importing the canonical
6
+ * `BuzzCoords` from `channel-route.ts`, purely to hold gateway.ts at its
7
+ * zero-headroom line-ratchet (switchroom#2996). That inlining is a drift hazard:
8
+ * nothing structurally ties the two shapes together, so an edit to one could
9
+ * silently diverge from the other.
10
+ *
11
+ * This file closes that hazard with a strict, invariant type-equality assertion
12
+ * that makes `tsc --noEmit` (the lint gate) FAIL the moment the shapes differ.
13
+ * Both imports are `import type` — fully erased at runtime, so this introduces
14
+ * NO runtime dependency and NO module-load side effect (in particular it does
15
+ * NOT load gateway.ts, which binds a UDS listener at import under prod). The
16
+ * file is imported by nothing; it exists only to be type-checked.
17
+ */
18
+
19
+ import type { CurrentTurn } from "./gateway.js";
20
+ import type { BuzzCoords } from "./channel-route.js";
21
+
22
+ /**
23
+ * Invariant type equality: `true` IFF `A` and `B` are mutually assignable with
24
+ * identical `readonly`/optional modifiers (the `(<T>() => …)` wrapper defeats
25
+ * the bivariant/structural leniency a plain `extends` pair would allow).
26
+ */
27
+ type TypeEq<A, B> =
28
+ (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
29
+
30
+ // `buzzCoords` is optional on CurrentTurn; compare its PRESENT shape to the
31
+ // canonical BuzzCoords. If these ever drift, `TypeEq<…>` becomes `false` and the
32
+ // `= true` initializer is a hard `tsc` error — the intended build break.
33
+ const _buzzCoordsIdentity: TypeEq<NonNullable<CurrentTurn["buzzCoords"]>, BuzzCoords> = true;
34
+ void _buzzCoordsIdentity;