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
@@ -169,6 +169,43 @@ export interface PreApprovedResultEvent {
169
169
  preApproved: boolean;
170
170
  }
171
171
 
172
+ /**
173
+ * Buzz co-channel — Phase 2b. Gateway → Buzz-sidecar peer: a request to
174
+ * publish (or correct) a Nostr channel message. Sent ONLY to the single
175
+ * duplex peer client that announced itself via `hello_buzz_peer`, never to a
176
+ * registered agent bridge. The sidecar's `publisher.ts` is the sole content-
177
+ * signer: it re-scrubs `payload.text` through `detectSecrets` before signing
178
+ * and answers with a `buzz_publish_result` carrying the same `correlationId`.
179
+ *
180
+ * `payload.kind` is restricted to `message` | `correction` in Phase 2b
181
+ * (reaction / approval / patch are deferred per design §3.1 / F4 — the Buzz
182
+ * desktop renders only a fixed content-kind allowlist). A `correction`
183
+ * carries the `targetEventId` of the already-published event it supersedes.
184
+ */
185
+ export interface OutboundToBuzzMessage {
186
+ type: "outbound_to_buzz";
187
+ /** Caller-generated id, echoed back in `buzz_publish_result`. */
188
+ correlationId: string;
189
+ /**
190
+ * The publishing agent. Validated for wire SHAPE only (AGENT_NAME_RE in
191
+ * `isValidClientToGateway`) and stamped by the hub itself (buzz-mirror sets it
192
+ * from its own `agentName`), so it is diagnostic here — the gateway does NOT
193
+ * cross-check it against a configured own-name (createIpcServer holds no such
194
+ * name). Impersonation is prevented structurally instead: only the registered
195
+ * duplex peer connection receives `outbound_to_buzz` and may answer it.
196
+ */
197
+ agentName: string;
198
+ /** Target NIP-29 channel (group) id, `["h", …]`. */
199
+ channelId: string;
200
+ /** NIP-10 reply target (the Buzz event being answered), when threading. */
201
+ replyToEventId?: string;
202
+ /** NIP-10 thread root, when threading into an existing conversation. */
203
+ threadRootId?: string;
204
+ payload:
205
+ | { kind: "message"; text: string }
206
+ | { kind: "correction"; text: string; targetEventId: string };
207
+ }
208
+
172
209
  export type GatewayToClient =
173
210
  | InboundMessage
174
211
  | PermissionEvent
@@ -181,7 +218,8 @@ export type GatewayToClient =
181
218
  | RolloutStatusPostedEvent
182
219
  | RolloutStatusEditedEvent
183
220
  | PendingPermissionStatusEvent
184
- | PreApprovedResultEvent;
221
+ | PreApprovedResultEvent
222
+ | OutboundToBuzzMessage;
185
223
 
186
224
  // === Bridge (Client) -> Gateway messages ===
187
225
 
@@ -671,6 +709,45 @@ export interface CheckPreApprovedMessage {
671
709
  unifiedDiff: string;
672
710
  }
673
711
 
712
+ /**
713
+ * Buzz co-channel — Phase 2b. The Buzz sidecar's one-time announcement that
714
+ * this connection is the DUPLEX publish peer, not an agent bridge. It carries
715
+ * NO `agentIndex` claim and NEVER registers a topic: the gateway parks it in a
716
+ * dedicated `buzzPeerClient` slot, marks it watchdog-exempt (it has no live
717
+ * `agentName`), and refuses a subsequent `register` on the same connection
718
+ * (and, conversely, refuses `hello_buzz_peer` on a client that already
719
+ * `register`ed) — the peer role and the agent-bridge role are mutually
720
+ * exclusive per design §3.2 / S7. `agentName` here is the fleet agent whose
721
+ * outbound this peer publishes; it is validated for wire SHAPE only
722
+ * (AGENT_NAME_RE) and used for logging — the gateway does NOT cross-check it
723
+ * against a configured own-name (it holds none). The peer role is secured
724
+ * structurally: a live peer cannot be displaced by a fresh hello, and only the
725
+ * peer connection may send `buzz_publish_result`.
726
+ */
727
+ export interface HelloBuzzPeerMessage {
728
+ type: "hello_buzz_peer";
729
+ agentName: string;
730
+ }
731
+
732
+ /**
733
+ * Buzz co-channel — Phase 2b. The sidecar's reply to an `outbound_to_buzz`:
734
+ * the outcome of the publish attempt. Advisory ONLY under `both` mode — the
735
+ * Telegram copy is the guaranteed delivery, so a `buzz_publish_result` with
736
+ * `ok: false` never fails or retries the answer, it only feeds the hub's
737
+ * correlation map (freeing the pending slot, logging, and — on success —
738
+ * recording the published `eventId` so a later `correction` can target it).
739
+ */
740
+ export interface BuzzPublishResultMessage {
741
+ type: "buzz_publish_result";
742
+ /** Echoes the `correlationId` from the originating `outbound_to_buzz`. */
743
+ correlationId: string;
744
+ ok: boolean;
745
+ /** The locally-computed (`getEventHash`) id of the signed event, on success. */
746
+ eventId?: string;
747
+ /** Diagnostic detail on failure (never carries scrubbed content). */
748
+ error?: string;
749
+ }
750
+
674
751
  export type ClientToGateway =
675
752
  | RegisterMessage
676
753
  | ToolCallMessage
@@ -692,4 +769,6 @@ export type ClientToGateway =
692
769
  | RolloutStatusPostMessage
693
770
  | RolloutStatusEditMessage
694
771
  | QueryPendingPermissionMessage
695
- | CheckPreApprovedMessage;
772
+ | CheckPreApprovedMessage
773
+ | HelloBuzzPeerMessage
774
+ | BuzzPublishResultMessage;
@@ -23,6 +23,9 @@ import type {
23
23
  SessionEventForward,
24
24
  ToolCallMessage,
25
25
  ToolCallResult,
26
+ HelloBuzzPeerMessage,
27
+ OutboundToBuzzMessage,
28
+ BuzzPublishResultMessage,
26
29
  } from "./ipc-protocol.js";
27
30
  import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
28
31
  import { OPERATOR_EVENT_KINDS } from "../operator-events.js";
@@ -160,6 +163,16 @@ export interface IpcServerOptions {
160
163
  client: IpcClient,
161
164
  msg: RolloutStatusEditMessage,
162
165
  ) => void | Promise<void>;
166
+ /**
167
+ * Buzz co-channel Phase 2b — the duplex Buzz peer's advisory publish outcome
168
+ * (`buzz_publish_result`). Handler feeds the buzz-mirror hub's correlation
169
+ * map: it frees the pending slot and, on success, records the published
170
+ * eventId so a later `correction` can target it. Fire-and-forget — under
171
+ * `both` mode the Telegram copy is the guaranteed delivery, so a failed
172
+ * publish never fails or retries the answer. Optional; a gateway without Buzz
173
+ * wired simply drops the message.
174
+ */
175
+ onBuzzPublishResult?: (client: IpcClient, msg: BuzzPublishResultMessage) => void;
163
176
  log?: (msg: string) => void;
164
177
  /**
165
178
  * How long (in ms) to wait without a heartbeat before force-closing the
@@ -178,6 +191,15 @@ export interface IpcClient {
178
191
  id: string;
179
192
  agentName: string | null;
180
193
  topicId: number | null;
194
+ /**
195
+ * Buzz co-channel Phase 2b (S7). True IFF this connection announced itself
196
+ * as the duplex Buzz publish peer via `hello_buzz_peer`. A peer NEVER holds
197
+ * an `agentName`/`agentIndex` slot; the flag makes the peer and agent-bridge
198
+ * roles mutually exclusive (a `register` is refused on a peer connection and
199
+ * vice-versa) and is what keeps the peer connection watchdog-exempt (it rides
200
+ * the existing `agentName === null` exemption — the peer has no heartbeat).
201
+ */
202
+ isBuzzPeer: boolean;
181
203
  send(msg: GatewayToClient): void;
182
204
  close(): void;
183
205
  isAlive(): boolean;
@@ -190,6 +212,14 @@ export interface IpcServer {
190
212
  broadcast(msg: GatewayToClient): void;
191
213
  getClient(agentName: string): IpcClient | undefined;
192
214
  clientCount(): number;
215
+ /**
216
+ * Buzz co-channel Phase 2b. Send an `outbound_to_buzz` request to the single
217
+ * duplex Buzz peer, if one is currently connected. Returns false (no send)
218
+ * when no peer has announced itself — the caller (buzz-mirror hub) treats a
219
+ * false return as "Buzz unreachable" and simply drops the mirror; the
220
+ * guaranteed Telegram copy already went out, so nothing fails or retries.
221
+ */
222
+ sendToBuzzPeer(msg: OutboundToBuzzMessage): boolean;
193
223
  close(): Promise<void>;
194
224
  }
195
225
 
@@ -493,6 +523,25 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
493
523
  || (m.text as string).length > RICH_MESSAGE_MAX_CHARS) return false;
494
524
  return true;
495
525
  }
526
+ case "hello_buzz_peer": {
527
+ // Buzz co-channel Phase 2b — the sidecar's one-time duplex-peer
528
+ // announcement. Wire shape only; the handler parks it in the dedicated
529
+ // buzzPeerClient slot and enforces role-disjointness with `register`.
530
+ return typeof m.agentName === "string"
531
+ && AGENT_NAME_RE.test(m.agentName as string);
532
+ }
533
+ case "buzz_publish_result": {
534
+ // Buzz co-channel Phase 2b — the sidecar's advisory publish outcome.
535
+ if (typeof m.correlationId !== "string"
536
+ || (m.correlationId as string).length === 0
537
+ || (m.correlationId as string).length > 64) return false;
538
+ if (typeof m.ok !== "boolean") return false;
539
+ if (m.eventId !== undefined
540
+ && (typeof m.eventId !== "string" || (m.eventId as string).length > 128)) return false;
541
+ if (m.error !== undefined
542
+ && (typeof m.error !== "string" || (m.error as string).length > 500)) return false;
543
+ return true;
544
+ }
496
545
  default:
497
546
  return false;
498
547
  }
@@ -522,10 +571,45 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
522
571
  onRequestConfigFinalize,
523
572
  onRolloutStatusPost,
524
573
  onRolloutStatusEdit,
574
+ onBuzzPublishResult,
525
575
  log = () => {},
526
576
  heartbeatTimeoutMs = 30_000,
527
577
  } = options;
528
578
 
579
+ // Buzz co-channel Phase 2b (S7) — the single duplex Buzz publish peer's
580
+ // connection, or null when none is connected. Parked here (never in
581
+ // agentIndex) so `outbound_to_buzz` addresses exactly one peer and the peer
582
+ // never shadows an agent-bridge slot. Nulled in removeClient on disconnect.
583
+ let buzzPeerClient: IpcClientImpl | null = null;
584
+
585
+ // Hub-side dedup ring for Buzz injects (fable MAJOR-2). The Buzz sidecar's
586
+ // durable journal covers the normal case, but a crash AFTER the gateway
587
+ // injects but BEFORE the sidecar records dedup would re-fire the turn on
588
+ // restart. This bounded in-memory ring drops a re-injected duplicate at the
589
+ // hub, keyed on the stable Buzz event id.
590
+ //
591
+ // SCOPE — buzz ONLY. This is the shared inject hot path (cron, reactions,
592
+ // resume, etc.). The check below fires exclusively for injects whose
593
+ // `inbound.meta.source === "buzz"` AND that carry a `buzz_event_id`; every
594
+ // other inject source flows through untouched, byte-identical to before. A
595
+ // buzz inject without a stable id (should not happen — inbound-map always
596
+ // stamps one) also flows through untouched rather than being dropped blind.
597
+ const BUZZ_INJECT_RING_MAX = 1024;
598
+ const buzzInjectSeen = new Set<string>();
599
+ const buzzInjectOrder: string[] = [];
600
+ /** Return true if this buzz event id was already injected (drop it); else
601
+ * record it and return false. Bounded FIFO eviction at RING_MAX entries. */
602
+ const buzzInjectIsDuplicate = (eventId: string): boolean => {
603
+ if (buzzInjectSeen.has(eventId)) return true;
604
+ buzzInjectSeen.add(eventId);
605
+ buzzInjectOrder.push(eventId);
606
+ if (buzzInjectOrder.length > BUZZ_INJECT_RING_MAX) {
607
+ const evicted = buzzInjectOrder.shift();
608
+ if (evicted !== undefined) buzzInjectSeen.delete(evicted);
609
+ }
610
+ return false;
611
+ };
612
+
529
613
  // Race-safe cleanup: rename the live socket to a .bak sidecar rather than
530
614
  // unlinking it. If the old gateway's delayed shutdown-cleanup later tries to
531
615
  // rename again, it targets .bak (already-moved) not the freshly-bound file.
@@ -565,6 +649,10 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
565
649
  if (client.topicId != null && topicIndex.get(client.topicId) === client) {
566
650
  topicIndex.delete(client.topicId);
567
651
  }
652
+ // Buzz co-channel Phase 2b — release the duplex peer slot if this was it,
653
+ // identity-checked (a fast peer reconnect may have already installed a new
654
+ // peer before this old connection's close runs).
655
+ if (buzzPeerClient === client) buzzPeerClient = null;
568
656
  loggedLegacyUpdatePlaceholder.delete(client.id);
569
657
  onClientDisconnected(client);
570
658
  log(`client disconnected: ${client.id} (agent=${client.agentName})`);
@@ -623,9 +711,22 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
623
711
  case "pty_partial":
624
712
  if (onPtyPartial) onPtyPartial(client, msg as PtyPartialForward);
625
713
  break;
626
- case "inject_inbound":
627
- if (onInjectInbound) onInjectInbound(client, msg as InjectInboundMessage);
714
+ case "inject_inbound": {
715
+ const injectMsg = msg as InjectInboundMessage;
716
+ // Hub-side Buzz dedup ring (fable MAJOR-2) — scoped strictly to
717
+ // meta.source==="buzz". A duplicate buzz event id (a re-inject after a
718
+ // crash between inject and the sidecar's dedup record) is dropped here;
719
+ // every non-buzz inject is unaffected.
720
+ const injMeta = (injectMsg.inbound as { meta?: Record<string, unknown> } | undefined)?.meta;
721
+ if (injMeta && injMeta.source === "buzz" && typeof injMeta.buzz_event_id === "string") {
722
+ if (buzzInjectIsDuplicate(injMeta.buzz_event_id)) {
723
+ log(`inject_inbound: dropped duplicate buzz event ${injMeta.buzz_event_id.slice(0, 12)} (hub dedup ring)`);
724
+ break;
725
+ }
726
+ }
727
+ if (onInjectInbound) onInjectInbound(client, injectMsg);
628
728
  break;
729
+ }
629
730
  case "send_outbound":
630
731
  if (onSendOutbound) onSendOutbound(client, msg as SendOutboundMessage);
631
732
  break;
@@ -831,6 +932,28 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
831
932
  // The handler replies `rollout_status_edited` (#4065) when it can; an
832
933
  // unwired gateway sends nothing and hostd's bounded wait expires.
833
934
  break;
935
+ case "hello_buzz_peer":
936
+ handleHelloBuzzPeer(client, msg as HelloBuzzPeerMessage);
937
+ break;
938
+ case "buzz_publish_result":
939
+ // Confused-deputy close (MAJOR-1a): ONLY the registered duplex Buzz peer
940
+ // may report a publish outcome. Without this gate any client (an agent
941
+ // MCP bridge, or a fresh anonymous connection) could forge a
942
+ // `buzz_publish_result` carrying a valid-looking correlationId and a
943
+ // foreign eventId, poisoning the hub's correlation map so a later
944
+ // correction signs against an arbitrary Nostr event. The peer→agent
945
+ // direction is already fenced (register-after-hello refused above); this
946
+ // mirrors that rigor on the agent→peer surface.
947
+ if (!client.isBuzzPeer) {
948
+ log(
949
+ `SECURITY: rejecting buzz_publish_result from non-peer connection ` +
950
+ `(agent=${client.agentName ?? "anonymous"} id=${client.id}) — only the ` +
951
+ `registered Buzz publish peer may report publish outcomes; dropped`,
952
+ );
953
+ break;
954
+ }
955
+ if (onBuzzPublishResult) onBuzzPublishResult(client, msg as BuzzPublishResultMessage);
956
+ break;
834
957
  case "update_placeholder":
835
958
  // Legacy recall.py IPC — placeholder UX was removed in #553 PR 5.
836
959
  // Soft-accepted so recall.py keeps working without modifying
@@ -846,7 +969,72 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
846
969
  }
847
970
  }
848
971
 
972
+ /**
973
+ * Buzz co-channel Phase 2b (S7). Accept a `hello_buzz_peer` announcement,
974
+ * parking the connection as the single duplex Buzz publish peer. Enforces
975
+ * role-disjointness as a CODE mechanism, not sidecar self-discipline:
976
+ * - a connection that already `register`ed (agentName set) or already
977
+ * announced as a peer is refused (close+drop);
978
+ * - the reciprocal refusal — a `register` on a peer connection — lives in
979
+ * handleRegister below.
980
+ * The peer NEVER touches agentIndex/topicIndex and carries no heartbeat, so
981
+ * it rides the watchdog's existing `agentName === null` exemption untouched.
982
+ */
983
+ function handleHelloBuzzPeer(client: IpcClientImpl, msg: HelloBuzzPeerMessage) {
984
+ if (client.agentName !== null || client.isBuzzPeer) {
985
+ log(
986
+ `rejecting hello_buzz_peer: connection already has a role ` +
987
+ `(agent=${client.agentName ?? "none"} isBuzzPeer=${client.isBuzzPeer}) — close+drop client=${client.id}`,
988
+ );
989
+ try { client.close(); } catch { /* nothing to do */ }
990
+ return;
991
+ }
992
+ // Impersonation guard (MAJOR-1b): a fresh connection must NOT be able to
993
+ // DISPLACE a LIVE Buzz peer. Without this an anonymous client could send
994
+ // `hello_buzz_peer`, replace the real sidecar in the buzzPeerClient slot,
995
+ // then receive every `outbound_to_buzz` and answer with forged
996
+ // `{ok:true, eventId:<foreign>}` results — poisoning `msgToBuzz` so a later
997
+ // edit_message signs a correction targeting an arbitrary foreign event.
998
+ //
999
+ // A legitimate sidecar reconnect is still honored: it only ever happens
1000
+ // AFTER the prior socket dropped, at which point the socket `close` handler
1001
+ // has run removeClient (nulling buzzPeerClient), OR — in the brief window
1002
+ // before that fires — the prior client is already `close()`d so isAlive()
1003
+ // is false. So we refuse displacement ONLY while the existing peer
1004
+ // connection is still alive; a dead/closed prior peer is freely replaceable.
1005
+ if (buzzPeerClient && buzzPeerClient !== client && buzzPeerClient.isAlive()) {
1006
+ log(
1007
+ `SECURITY: rejecting hello_buzz_peer — a LIVE Buzz peer is already ` +
1008
+ `connected (live_id=${buzzPeerClient.id} rejected_id=${client.id}); ` +
1009
+ `refusing displacement, close+drop`,
1010
+ );
1011
+ try { client.close(); } catch { /* nothing to do */ }
1012
+ return;
1013
+ }
1014
+ // Replace a dead/closed prior peer connection (e.g. a sidecar reconnect
1015
+ // whose predecessor's socket already dropped) cleanly.
1016
+ if (buzzPeerClient && buzzPeerClient !== client) {
1017
+ log(`hello_buzz_peer: replacing prior (dead) buzz peer (prior_id=${buzzPeerClient.id} new_id=${client.id})`);
1018
+ try { buzzPeerClient.close(); } catch { /* nothing to do */ }
1019
+ }
1020
+ client.isBuzzPeer = true;
1021
+ buzzPeerClient = client;
1022
+ log(`registered buzz publish peer for agent=${msg.agentName} id=${client.id}`);
1023
+ }
1024
+
849
1025
  function handleRegister(client: IpcClientImpl, msg: RegisterMessage) {
1026
+ // Buzz co-channel Phase 2b (S7) — reciprocal role-disjointness: a
1027
+ // connection that announced itself as the duplex Buzz peer must never be
1028
+ // allowed to claim an agentIndex slot. Refuse server-side (a code check,
1029
+ // not sidecar self-discipline).
1030
+ if (client.isBuzzPeer) {
1031
+ log(
1032
+ `rejecting register: connection is the Buzz publish peer, not an agent bridge ` +
1033
+ `(close+drop client=${client.id})`,
1034
+ );
1035
+ try { client.close(); } catch { /* nothing to do */ }
1036
+ return;
1037
+ }
850
1038
  // Defence in depth for #430. The bridge refuses to register
851
1039
  // without SWITCHROOM_AGENT_NAME (set in start.sh per agent), but
852
1040
  // an older bridge or a third-party caller could still send the
@@ -919,6 +1107,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
919
1107
  id: string;
920
1108
  agentName: string | null = null;
921
1109
  topicId: number | null = null;
1110
+ isBuzzPeer = false;
922
1111
  lastHeartbeat: number = Date.now();
923
1112
  _socket: import("bun").Socket<SocketData>;
924
1113
  private _closed = false;
@@ -1055,6 +1244,12 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
1055
1244
  return clients.size;
1056
1245
  },
1057
1246
 
1247
+ sendToBuzzPeer(msg: OutboundToBuzzMessage): boolean {
1248
+ if (!buzzPeerClient || !buzzPeerClient.isAlive()) return false;
1249
+ buzzPeerClient.send(msg);
1250
+ return true;
1251
+ },
1252
+
1058
1253
  async close(): Promise<void> {
1059
1254
  // Stop the heartbeat watchdog before closing clients so it doesn't
1060
1255
  // log spurious evictions during planned shutdown.
@@ -69,6 +69,7 @@ import {
69
69
  } from '../hooks/audience-classify.mjs'
70
70
  import { queueFloodBlockedReply } from './flood-reply-queue.js'
71
71
  import { resolveChatIdFallback } from './chat-id-fallback.js'
72
+ import { getBuzzMirror } from './buzz-mirror.js'
72
73
  import { isFinalAnswerReply, isSubstantiveFinalReply, shouldJournalReplySiteDelivery } from '../final-answer-detect.js'
73
74
  import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
74
75
  import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
@@ -756,6 +757,30 @@ export interface VoiceOutPlan {
756
757
  ttsChunks: string[]
757
758
  }
758
759
 
760
+ /**
761
+ * Resolve the Buzz-mirror NIP-10 antecedent key for an outbound answer, or
762
+ * `undefined` to mirror FLAT. Pure + deterministic so the gating is testable
763
+ * without the full send harness. Returns `${chatId}:${replyTo}` only when a
764
+ * genuine, renderable reply antecedent exists; `undefined` when:
765
+ * - there is no antecedent (`replyTo == null`), or
766
+ * - `replyTo` is not a finite number (a non-numeric model `reply_to` coerces to
767
+ * `NaN`; #4301 — never build a bogus `chat:NaN` key the mirror logs as a
768
+ * real miss), or
769
+ * - `replyMode === 'off'` (#4300 — the Telegram copy renders NO reply, so the
770
+ * Buzz mirror must stay flat too; without this the Buzz copy visibly threads
771
+ * while the Telegram copy does not — a surface divergence).
772
+ */
773
+ export function resolveMirrorAntecedentKey(
774
+ chatId: string,
775
+ replyTo: number | undefined,
776
+ replyMode: string,
777
+ ): string | undefined {
778
+ if (replyTo == null || !Number.isFinite(replyTo) || replyMode === 'off') {
779
+ return undefined
780
+ }
781
+ return `${chatId}:${replyTo}`
782
+ }
783
+
759
784
  export interface SendReplyRequest {
760
785
  /** Raw `reply` tool args, exactly as the MCP dispatch received them. */
761
786
  args: Record<string, unknown>
@@ -834,6 +859,10 @@ export interface SendReplyGatewayDeps {
834
859
  resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates }
835
860
  findTurnByOriginId(originTurnId: string | null | undefined): CurrentTurn | null
836
861
  findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null
862
+ /** Buzz co-channel Phase 2b (S1) — the same chat-wide latest-ended lookup the
863
+ * owner-resolution wiring uses. Read here ONLY to compute the S1 owner-guard
864
+ * input `hasRecentDifferentOriginTurn`; a no-op when Buzz mirroring is off. */
865
+ findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): CurrentTurn | null
837
866
  resolveAnswerThreadWithLog(
838
867
  chatId: string,
839
868
  explicitThreadId: number | undefined,
@@ -913,7 +942,7 @@ export async function sendReply(
913
942
  lockedBot, robustApiCall, swallowingApiCall,
914
943
  loadAccess, redactOutboundText, assertAllowedChat, assertSendable,
915
944
  statusKey, streamKey,
916
- resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
945
+ resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat,
917
946
  resolveAnswerThreadWithLog, resolveThreadId,
918
947
  getLatestInboundMessageId, getLastSubagentHandbackAt, subagentReplyAuthority, recordOutbound,
919
948
  emissionAuthorityFor, clearActivitySummary,
@@ -1540,10 +1569,19 @@ export async function sendReply(
1540
1569
  )
1541
1570
  }
1542
1571
 
1572
+ // #4301: track whether `reply_to` came from the quote-opt-in DEFAULT (the
1573
+ // latest inbound user message) rather than an explicit/model-supplied value.
1574
+ // The default antecedent is never in the Buzz correlation store, so its mirror
1575
+ // lookup always misses — passing this flag lets the mirror log that expected
1576
+ // flat fallback quietly instead of as an eviction "MISS".
1577
+ let antecedentFromQuoteOptInDefault = false
1543
1578
  if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
1544
1579
  try {
1545
1580
  const latest = getLatestInboundMessageId(chat_id, threadId ?? null)
1546
- if (latest != null) reply_to = latest
1581
+ if (latest != null) {
1582
+ reply_to = latest
1583
+ antecedentFromQuoteOptInDefault = true
1584
+ }
1547
1585
  } catch (err) {
1548
1586
  process.stderr.write(`telegram gateway: quote-reply lookup failed: ${(err as Error).message}\n`)
1549
1587
  }
@@ -2585,6 +2623,51 @@ export async function sendReply(
2585
2623
  if (shouldJournalReplySiteDelivery({ text: rawText, disableNotification: modelDisableNotification })) {
2586
2624
  journalExternalDelivery({ turnNonce: t?.turnId ?? null, text, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: true })
2587
2625
  }
2626
+ // ── Buzz co-channel Phase 2b mirror hook (S1/S4) ─────────────────────────
2627
+ // STRICTLY downstream of the guaranteed Telegram delivery above: this runs
2628
+ // only inside `sentIds.length > 0` (a Telegram copy landed) and is a
2629
+ // byte-identical no-op when Buzz is disabled (`getBuzzMirror()` is null).
2630
+ // Never throws — the hub swallows its own errors — so a Buzz mirror can
2631
+ // never fail, delay, or alter the Telegram answer (the core invariant), and
2632
+ // the tool result the model sees reflects the Telegram copy only (S4).
2633
+ const buzzMirror = getBuzzMirror()
2634
+ if (buzzMirror !== null) {
2635
+ const { turn: mOwnerTurn, tier: mOwnerTier } = resolveReplyOwnerTurn(turn, chat_id, args)
2636
+ const ownerOrigin = mOwnerTurn?.originChannel ?? 'telegram'
2637
+ const ownerTurnId = mOwnerTurn?.turnId ?? null
2638
+ // S1 inputs. ownerEchoed: the reply positively echoed the owner turn's id
2639
+ // (the `origin` tier is the only one that binds by `origin_turn_id`).
2640
+ const ownerEchoed = mOwnerTier === 'origin'
2641
+ // hasRecentDifferentOriginTurn: is there a recent turn of a DIFFERENT
2642
+ // origin than the resolved owner (the live turn, or the chat's latest
2643
+ // ended turn) that this reply could otherwise have belonged to? Deterministic.
2644
+ const latestEnded = findLatestTurnForChat(chat_id, { endedOnly: true })
2645
+ const hasRecentDifferentOriginTurn = [turn, latestEnded].some(
2646
+ (c) => c != null && c.turnId !== ownerTurnId && c.originChannel !== ownerOrigin,
2647
+ )
2648
+ buzzMirror.mirrorReplyDelivered({
2649
+ scrubbedText: text,
2650
+ ownerOriginChannel: ownerOrigin,
2651
+ ownerBuzzCoords: mOwnerTurn?.buzzCoords,
2652
+ ownerEchoed,
2653
+ hasRecentDifferentOriginTurn,
2654
+ telegramMessageKeys: sentIds.map((id) => `${chat_id}:${id}`),
2655
+ // NIP-10 outbound thread continuity: the Telegram message THIS answer
2656
+ // replied to (its finalized `reply_to`, whether model-supplied or the
2657
+ // quote-opt-in default). The mirror resolves it against the durable
2658
+ // correlation store and threads under it only on a HIT (a previously-
2659
+ // mirrored answer); a user inbound / evicted key misses → flat.
2660
+ //
2661
+ // Guards:
2662
+ // - #4300: when `replyMode === 'off'` the Telegram copy renders NO
2663
+ // reply, so DON'T stamp the antecedent — keep the Buzz copy flat too
2664
+ // (surface parity; no threading the Buzz copy visibly threads on).
2665
+ // - #4301: `Number.isFinite` drops a non-numeric `reply_to` (→ `NaN`)
2666
+ // so it never becomes a bogus `chat:NaN` key that logs as a real miss.
2667
+ antecedentTelegramMessageKey: resolveMirrorAntecedentKey(chat_id, reply_to, replyMode),
2668
+ antecedentIsQuoteOptInDefault: antecedentFromQuoteOptInDefault,
2669
+ })
2670
+ }
2588
2671
  }
2589
2672
  return { content: [{ type: 'text', text: result }] }
2590
2673
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Writer for the one-shot `.pending-turn.env` diagnostic file (Stage 4 of
3
+ * simplify-restart, #250) — extracted verbatim from gateway.ts (#2996
4
+ * ratchet discipline: new inline code must not land in gateway.ts).
5
+ *
6
+ * The gateway writes `<agentDir>/.pending-turn.env` at boot when the
7
+ * previous shutdown left an interrupted turn; start.sh sources and then
8
+ * consumes it. These vars are PASSIVE forensic context for the wake-audit
9
+ * / "why did you restart" protocols — the real wake signal is the
10
+ * synthesized resume inbound, not this file.
11
+ */
12
+
13
+ import { existsSync, renameSync, rmSync, writeFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import type { Turn } from '../registry/turns-schema.js'
16
+
17
+ /**
18
+ * Write (or clear) the pending-turn env file. Atomic tmp+rename: a crash
19
+ * mid-write must never leave a truncated file that start.sh `source`s —
20
+ * partial SWITCHROOM_PENDING_* vars or a malformed line would break shell
21
+ * parsing inside the source. Never throws (logs via `log`).
22
+ */
23
+ export function writePendingTurnEnv(
24
+ agentDir: string,
25
+ pending: Turn | null,
26
+ log: (line: string) => void = (l) => process.stderr.write(l),
27
+ ): void {
28
+ const pendingEnvPath = join(agentDir, '.pending-turn.env')
29
+ try {
30
+ if (pending != null) {
31
+ const lines = [
32
+ `SWITCHROOM_PENDING_TURN=true`,
33
+ `SWITCHROOM_PENDING_TURN_KEY=${pending.turn_key}`,
34
+ `SWITCHROOM_PENDING_CHAT_ID=${pending.chat_id}`,
35
+ // Tri-state thread signal for the handoff-briefing scope resolver.
36
+ // A pending Turn ALWAYS knows its thread: a numbered forum topic, or
37
+ // NULL (a DM / forum General topic). Emit the numeric id for a topic,
38
+ // and the literal sentinel `NULL` when the thread is genuinely null —
39
+ // so bin/handoff-briefing.sh scopes the reorientation to `thread_id IS
40
+ // NULL` instead of falling back to chat-only (all-threads) scope. An
41
+ // empty value is reserved for "thread unknown"; the writer never emits
42
+ // it, but an older gateway would, and the resolver treats empty as the
43
+ // safe chat-only fallback.
44
+ pending.thread_id != null
45
+ ? `SWITCHROOM_PENDING_THREAD_ID=${pending.thread_id}`
46
+ : `SWITCHROOM_PENDING_THREAD_ID=NULL`,
47
+ pending.last_user_msg_id != null
48
+ ? `SWITCHROOM_PENDING_USER_MSG_ID=${pending.last_user_msg_id}`
49
+ : `SWITCHROOM_PENDING_USER_MSG_ID=`,
50
+ `SWITCHROOM_PENDING_ENDED_VIA=${pending.ended_via ?? 'unknown'}`,
51
+ `SWITCHROOM_PENDING_STARTED_AT=${pending.started_at}`,
52
+ pending.interrupt_reason != null
53
+ ? `SWITCHROOM_PENDING_INTERRUPT_REASON=${pending.interrupt_reason}`
54
+ : `SWITCHROOM_PENDING_INTERRUPT_REASON=`,
55
+ ]
56
+ const pendingEnvTmp = `${pendingEnvPath}.tmp-${process.pid}`
57
+ writeFileSync(pendingEnvTmp, lines.join('\n') + '\n', { mode: 0o600 })
58
+ renameSync(pendingEnvTmp, pendingEnvPath)
59
+ log(
60
+ `telegram gateway: pending-turn env written to ${pendingEnvPath} ` +
61
+ `turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'}\n`,
62
+ )
63
+ } else if (existsSync(pendingEnvPath)) {
64
+ rmSync(pendingEnvPath, { force: true })
65
+ log(`telegram gateway: pending-turn env cleared (clean previous shutdown)\n`)
66
+ }
67
+ } catch (err) {
68
+ log(`telegram gateway: pending-turn env write failed (${(err as Error).message})\n`)
69
+ }
70
+ }
@@ -77,6 +77,7 @@ import { FlushCompletionTracker } from '../flushed-turn-supersede.js'
77
77
  import { subagentReplyAuthority } from './subagent-reply-authority.js'
78
78
  import { sessionConsumeSignal } from './session-consume-signal.js'
79
79
  import { decideTerminalReason, deriveTurnRole } from '../turn-liveness-floor.js'
80
+ import { parseChannelOrigin, isBuzzTurnRoutingEnabled } from './channel-route.js'
80
81
  import { chatKey, chatKeyWithSuffix } from './chat-key.js'
81
82
  import { deriveTurnId } from './derive-turn-id.js'
82
83
  import { EMISSION_AUTHORITY_ENABLED, EmissionAuthority } from './emission-authority.js'
@@ -151,6 +152,17 @@ const QUEUED_CARD_HTML = '⏳ Queued — waiting for the current task to finish
151
152
  const QUEUED_CARD_FOLDED_HTML = '✅ Folded into the current task.'
152
153
  const QUEUED_CARD_EXPIRED_HTML = '⚠️ This queued message timed out before it could start.'
153
154
 
155
+ // Buzz co-channel — Phase 2a origin stamp gate (Finding 10). The per-turn
156
+ // `parseChannelOrigin` call is invoked ONLY when BOTH hold:
157
+ // · BUZZ_ENABLED — the per-agent projection `src/agents/compose.ts` sets in
158
+ // the container env ONLY when `channels.buzz.enabled === true`; absent for
159
+ // every Telegram-only agent.
160
+ // · SWITCHROOM_BUZZ_TURN_ROUTING !== '0' — the Phase 2a kill switch.
161
+ // When either is off, the ctor stamps a plain Telegram origin without ever
162
+ // calling the parser, so the Telegram-only hot path is byte-for-byte unchanged.
163
+ const BUZZ_ENABLED = process.env.BUZZ_ENABLED === '1' || process.env.BUZZ_ENABLED === 'true'
164
+ const BUZZ_ORIGIN_STAMP_ACTIVE = BUZZ_ENABLED && isBuzzTurnRoutingEnabled()
165
+
154
166
  /** The `enqueue` envelope fields `beginTurn` needs — the SessionEvent minus its
155
167
  * discriminant. A parked entry additionally carries `parkedAt` for the TTL. */
156
168
  export interface TurnStartEnvelope {
@@ -576,6 +588,15 @@ function beginTurn(deps: StreamRenderDeps, ev: TurnStartEnvelope): void {
576
588
  gatewayReceiveAt: startedAt,
577
589
  // #2527 — stamp the loop role once, from the enqueue envelope.
578
590
  role: deriveTurnRole(ev.rawContent),
591
+ // Buzz co-channel — Phase 2a. Stamp the immutable origin provenance once,
592
+ // from the same enqueue envelope. Gated (Finding 10): when Buzz is off for
593
+ // this agent (or the kill switch is set) the parser is never called and
594
+ // the turn defaults to a plain Telegram origin — no coords, hot path
595
+ // untouched. When active, `parseChannelOrigin` reads the outer channel
596
+ // tag's meta-hoisted `source="buzz"` + coords (see `channel-route.ts`).
597
+ ...(BUZZ_ORIGIN_STAMP_ACTIVE
598
+ ? parseChannelOrigin(ev.rawContent)
599
+ : { originChannel: 'telegram' as const }),
579
600
  // PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Only a
580
601
  // synthetic represent/owed-reply turn carries this; a foreground turn
581
602
  // leaves it undefined and the cross-turn card-OPEN gate is inert.
@@ -176,6 +176,18 @@ export const INBOUND_SOURCE_CLASSIFICATION: Record<string, { decoupledCompletion
176
176
  mental_model_proposal_failed: { decoupledCompletion: false },
177
177
  webhook: { decoupledCompletion: false },
178
178
  linear: { decoupledCompletion: false },
179
+ // Buzz co-channel (Phase 1): a Nostr kind:9 group message the buzz sidecar
180
+ // injects onto the gateway IPC queue as its OWN live inbound turn (anonymous
181
+ // inject, `meta.source="buzz"`) — never a decoupled completion resolving a
182
+ // different ended turn, so it must NOT stamp.
183
+ buzz: { decoupledCompletion: false },
184
+ // Gateway boot briefing (session_continuity.briefing: gateway): a synthetic
185
+ // FIRST user turn the gateway assembles from durable history and injects over
186
+ // the spool (`<channel source="boot_briefing">`, boot-briefing-builder.ts).
187
+ // Like the resume_* synthetics it lands as its OWN live inbound turn — its
188
+ // briefing reply resolves the live tier for its own turnId and cannot
189
+ // supersede a different ended turn's record — so it must NOT stamp.
190
+ boot_briefing: { decoupledCompletion: false },
179
191
  }
180
192
 
181
193
  /**