switchroom 0.19.48 → 0.20.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 (50) hide show
  1. package/dist/agent-scheduler/index.js +18 -1
  2. package/dist/auth-broker/index.js +19 -2
  3. package/dist/buzz-gateway/index.js +9207 -0
  4. package/dist/cli/notion-write-pretool.mjs +18 -1
  5. package/dist/cli/switchroom.js +63 -4
  6. package/dist/host-control/main.js +20 -3
  7. package/dist/vault/approvals/kernel-server.js +19 -2
  8. package/dist/vault/broker/server.js +19 -2
  9. package/package.json +4 -3
  10. package/profiles/_base/start.sh.hbs +78 -1
  11. package/profiles/default/CLAUDE.md.hbs +1 -1
  12. package/skills/dev-protocol/SKILL.md +30 -1
  13. package/skills/switchroom-architecture/SKILL.md +5 -0
  14. package/skills/switchroom-cli/SKILL.md +1 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  16. package/telegram-plugin/dist/gateway/gateway.js +1149 -247
  17. package/telegram-plugin/dist/server.js +7 -4
  18. package/telegram-plugin/gateway/boot-briefing-builder.ts +458 -0
  19. package/telegram-plugin/gateway/boot-briefing-wiring.ts +170 -0
  20. package/telegram-plugin/gateway/buzz-mirror.ts +329 -0
  21. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  22. package/telegram-plugin/gateway/channel-route.ts +272 -0
  23. package/telegram-plugin/gateway/gateway.ts +73 -81
  24. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  25. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  26. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  27. package/telegram-plugin/gateway/outbound-send-path.ts +37 -1
  28. package/telegram-plugin/gateway/pending-turn-env.ts +61 -0
  29. package/telegram-plugin/gateway/stream-render.ts +21 -0
  30. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  31. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  32. package/telegram-plugin/history.ts +15 -0
  33. package/telegram-plugin/llm-error-present.ts +9 -4
  34. package/telegram-plugin/model-unavailable.ts +4 -0
  35. package/telegram-plugin/operator-events.fixtures.json +12 -12
  36. package/telegram-plugin/operator-events.ts +81 -9
  37. package/telegram-plugin/session-tail.ts +7 -1
  38. package/telegram-plugin/tests/boot-briefing-builder.test.ts +604 -0
  39. package/telegram-plugin/tests/buzz-mirror.test.ts +242 -0
  40. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  41. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  42. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  43. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  44. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  45. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  46. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  47. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  48. package/telegram-plugin/voice-normalize-text.ts +5 -0
  49. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  50. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -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'
@@ -834,6 +835,10 @@ export interface SendReplyGatewayDeps {
834
835
  resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): { turn: CurrentTurn | null; tier: ReplyOwnerTier; candidates: ReplyOwnerCandidates }
835
836
  findTurnByOriginId(originTurnId: string | null | undefined): CurrentTurn | null
836
837
  findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null
838
+ /** Buzz co-channel Phase 2b (S1) — the same chat-wide latest-ended lookup the
839
+ * owner-resolution wiring uses. Read here ONLY to compute the S1 owner-guard
840
+ * input `hasRecentDifferentOriginTurn`; a no-op when Buzz mirroring is off. */
841
+ findLatestTurnForChat(chatId: string, opts: { endedOnly: boolean }): CurrentTurn | null
837
842
  resolveAnswerThreadWithLog(
838
843
  chatId: string,
839
844
  explicitThreadId: number | undefined,
@@ -913,7 +918,7 @@ export async function sendReply(
913
918
  lockedBot, robustApiCall, swallowingApiCall,
914
919
  loadAccess, redactOutboundText, assertAllowedChat, assertSendable,
915
920
  statusKey, streamKey,
916
- resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
921
+ resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat,
917
922
  resolveAnswerThreadWithLog, resolveThreadId,
918
923
  getLatestInboundMessageId, getLastSubagentHandbackAt, subagentReplyAuthority, recordOutbound,
919
924
  emissionAuthorityFor, clearActivitySummary,
@@ -2585,6 +2590,37 @@ export async function sendReply(
2585
2590
  if (shouldJournalReplySiteDelivery({ text: rawText, disableNotification: modelDisableNotification })) {
2586
2591
  journalExternalDelivery({ turnNonce: t?.turnId ?? null, text, tgMessageId: sentIds[sentIds.length - 1], replyAlreadyDeliveredThisTurn: true })
2587
2592
  }
2593
+ // ── Buzz co-channel Phase 2b mirror hook (S1/S4) ─────────────────────────
2594
+ // STRICTLY downstream of the guaranteed Telegram delivery above: this runs
2595
+ // only inside `sentIds.length > 0` (a Telegram copy landed) and is a
2596
+ // byte-identical no-op when Buzz is disabled (`getBuzzMirror()` is null).
2597
+ // Never throws — the hub swallows its own errors — so a Buzz mirror can
2598
+ // never fail, delay, or alter the Telegram answer (the core invariant), and
2599
+ // the tool result the model sees reflects the Telegram copy only (S4).
2600
+ const buzzMirror = getBuzzMirror()
2601
+ if (buzzMirror !== null) {
2602
+ const { turn: mOwnerTurn, tier: mOwnerTier } = resolveReplyOwnerTurn(turn, chat_id, args)
2603
+ const ownerOrigin = mOwnerTurn?.originChannel ?? 'telegram'
2604
+ const ownerTurnId = mOwnerTurn?.turnId ?? null
2605
+ // S1 inputs. ownerEchoed: the reply positively echoed the owner turn's id
2606
+ // (the `origin` tier is the only one that binds by `origin_turn_id`).
2607
+ const ownerEchoed = mOwnerTier === 'origin'
2608
+ // hasRecentDifferentOriginTurn: is there a recent turn of a DIFFERENT
2609
+ // origin than the resolved owner (the live turn, or the chat's latest
2610
+ // ended turn) that this reply could otherwise have belonged to? Deterministic.
2611
+ const latestEnded = findLatestTurnForChat(chat_id, { endedOnly: true })
2612
+ const hasRecentDifferentOriginTurn = [turn, latestEnded].some(
2613
+ (c) => c != null && c.turnId !== ownerTurnId && c.originChannel !== ownerOrigin,
2614
+ )
2615
+ buzzMirror.mirrorReplyDelivered({
2616
+ scrubbedText: text,
2617
+ ownerOriginChannel: ownerOrigin,
2618
+ ownerBuzzCoords: mOwnerTurn?.buzzCoords,
2619
+ ownerEchoed,
2620
+ hasRecentDifferentOriginTurn,
2621
+ telegramMessageKeys: sentIds.map((id) => `${chat_id}:${id}`),
2622
+ })
2623
+ }
2588
2624
  }
2589
2625
  return { content: [{ type: 'text', text: result }] }
2590
2626
  }
@@ -0,0 +1,61 @@
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
+ pending.thread_id != null
36
+ ? `SWITCHROOM_PENDING_THREAD_ID=${pending.thread_id}`
37
+ : `SWITCHROOM_PENDING_THREAD_ID=`,
38
+ pending.last_user_msg_id != null
39
+ ? `SWITCHROOM_PENDING_USER_MSG_ID=${pending.last_user_msg_id}`
40
+ : `SWITCHROOM_PENDING_USER_MSG_ID=`,
41
+ `SWITCHROOM_PENDING_ENDED_VIA=${pending.ended_via ?? 'unknown'}`,
42
+ `SWITCHROOM_PENDING_STARTED_AT=${pending.started_at}`,
43
+ pending.interrupt_reason != null
44
+ ? `SWITCHROOM_PENDING_INTERRUPT_REASON=${pending.interrupt_reason}`
45
+ : `SWITCHROOM_PENDING_INTERRUPT_REASON=`,
46
+ ]
47
+ const pendingEnvTmp = `${pendingEnvPath}.tmp-${process.pid}`
48
+ writeFileSync(pendingEnvTmp, lines.join('\n') + '\n', { mode: 0o600 })
49
+ renameSync(pendingEnvTmp, pendingEnvPath)
50
+ log(
51
+ `telegram gateway: pending-turn env written to ${pendingEnvPath} ` +
52
+ `turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'}\n`,
53
+ )
54
+ } else if (existsSync(pendingEnvPath)) {
55
+ rmSync(pendingEnvPath, { force: true })
56
+ log(`telegram gateway: pending-turn env cleared (clean previous shutdown)\n`)
57
+ }
58
+ } catch (err) {
59
+ log(`telegram gateway: pending-turn env write failed (${(err as Error).message})\n`)
60
+ }
61
+ }
@@ -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
  /**
@@ -0,0 +1,172 @@
1
+ /**
2
+ * user-failure-notices.ts — gateway-side handling of the plain-language
3
+ * "couldn't complete that" user notice, plus the `transport-transient`
4
+ * classifier kind (a mid-response stream abort reaching Anthropic).
5
+ *
6
+ * Two concerns, one subsystem (the plain user-failure-notice send side):
7
+ *
8
+ * 1. `emitTransportTransientEvent` — the calm path for a `transport-transient`
9
+ * operator event. Claude Code emits `error: "server_error"` with NO HTTP
10
+ * status on a mid-response stream abort; that is a transport failure, not an
11
+ * account/auth/quota fault, and Claude retries it internally. So there is NO
12
+ * broadcast card (a Reauth card would be the wrong remedy and reach every
13
+ * chat). Instead: record for `/status` history, and DEFER one plain user
14
+ * notice to the turn-end gate (`pending-user-notice.ts`) so a genuinely
15
+ * reply-less dead turn still gets one calm line while a recovered retry
16
+ * sends nothing. A BURST — >=3 terminal transport-transient events for one
17
+ * agent within ~10min — escalates to exactly ONE operator-only, Dismiss-only
18
+ * card ("repeated stream failures reaching Anthropic"), so honest
19
+ * suppression never hides a genuinely degraded pipe.
20
+ *
21
+ * 2. `flushDeferredUserNotices` — the turn-end send loop for notices the gate
22
+ * released (moved here from the gateway; see #3293 finding 1).
23
+ *
24
+ * Pure orchestration: every side effect (record, schedule, resolve, send, log,
25
+ * clock) crosses in through `UserFailureNoticeDeps`, so the outcomes are
26
+ * unit-testable with fakes and no gateway/grammy/IPC coupling leaks in.
27
+ */
28
+
29
+ import { escapeMarkdown } from '../format.js'
30
+ import type { InlineKeyboardMarkup, OperatorEvent } from '../operator-events.js'
31
+ import type { PendingUserNotice } from '../pending-user-notice.js'
32
+
33
+ // ─── transport-transient escalation counter (pure) ───────────────────────────
34
+
35
+ const ESCALATION_WINDOW_MS = 10 * 60_000
36
+ const ESCALATION_THRESHOLD = 3
37
+
38
+ /** Per-agent sliding window of terminal transport-transient event timestamps. */
39
+ const transportEscalation = new Map<string, number[]>()
40
+
41
+ /**
42
+ * Record one terminal transport-transient event for `agent` at `now` and decide
43
+ * whether it crosses the escalation threshold. Returns `true` at most once per
44
+ * burst: crossing the threshold RESETS the window so a sustained outage emits
45
+ * one operator card per ~`windowMs`, not one per event.
46
+ */
47
+ export function noteTransportTransientAndShouldEscalate(
48
+ agent: string,
49
+ now: number,
50
+ opts?: { windowMs?: number; threshold?: number },
51
+ ): boolean {
52
+ const windowMs = opts?.windowMs ?? ESCALATION_WINDOW_MS
53
+ const threshold = opts?.threshold ?? ESCALATION_THRESHOLD
54
+ const recent = (transportEscalation.get(agent) ?? []).filter((t) => now - t < windowMs)
55
+ recent.push(now)
56
+ if (recent.length >= threshold) {
57
+ transportEscalation.set(agent, [])
58
+ return true
59
+ }
60
+ transportEscalation.set(agent, recent)
61
+ return false
62
+ }
63
+
64
+ /** Test/reauth-recovery helper: forget the escalation window (all or one agent). */
65
+ export function resetTransportTransientEscalation(agent?: string): void {
66
+ if (agent == null) transportEscalation.clear()
67
+ else transportEscalation.delete(agent)
68
+ }
69
+
70
+ /**
71
+ * The ONE operator-only card a transport-transient BURST escalates to. Dismiss-
72
+ * only — no Reauth (a dropped stream is not a credential fault), no failover
73
+ * (every account hits the same degraded pipe).
74
+ */
75
+ export function renderTransportEscalationCard(agent: string): {
76
+ text: string
77
+ keyboard: InlineKeyboardMarkup
78
+ } {
79
+ const a = escapeMarkdown(agent)
80
+ return {
81
+ text: [
82
+ `🔌 **Repeated stream failures** reaching Anthropic for **${a}**.`,
83
+ `3+ mid-response aborts within ~10 min. Turns retry automatically; if this persists, Anthropic's API may be degraded.`,
84
+ ].join('\n'),
85
+ keyboard: {
86
+ inline_keyboard: [
87
+ [{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(agent)}` }],
88
+ ],
89
+ },
90
+ }
91
+ }
92
+
93
+ // ─── Deps + orchestration ────────────────────────────────────────────────────
94
+
95
+ export interface UserFailureNoticeDeps {
96
+ /** Injectable clock. */
97
+ now(): number
98
+ /** Allowlist chats (broadcast audience). */
99
+ allowFrom(): readonly string[]
100
+ /** Topic key of the live turn, or undefined (agent-level / between turns). */
101
+ liveTurnKey(): string | undefined
102
+ /** Persist to /status history (best-effort — must swallow its own errors). */
103
+ record(event: OperatorEvent): void
104
+ /** Schedule the turn-end-gated plain user notice for `chatIds`. */
105
+ scheduleUserNotice(input: {
106
+ chatIds: string[]
107
+ agent: string
108
+ kind: string
109
+ key: string | undefined
110
+ atMs: number
111
+ }): void
112
+ /** Release the notices resolved by this turn end (may be empty). */
113
+ resolveNotices(turnDeliveredReply: boolean, turnKey: string): PendingUserNotice[]
114
+ /** Topic-aware raw send (fire-and-forget). `keyboard` omitted → plain message. */
115
+ send(chatId: string, text: string, keyboard?: InlineKeyboardMarkup): void
116
+ /** stderr line (the gateway prefixes "telegram gateway: "). */
117
+ log(msg: string): void
118
+ }
119
+
120
+ /**
121
+ * Handle a `transport-transient` operator event: record it, defer the plain
122
+ * user notice to turn-end, and — on a burst — post exactly one operator-only
123
+ * escalation card. Never sends a broadcast card.
124
+ */
125
+ export function emitTransportTransientEvent(
126
+ event: OperatorEvent,
127
+ deps: UserFailureNoticeDeps,
128
+ ): void {
129
+ const now = deps.now()
130
+ deps.record(event)
131
+ const escalate = noteTransportTransientAndShouldEscalate(event.agent, now)
132
+ deps.log(
133
+ `transport-transient agent=${event.agent} escalate=${escalate} ` +
134
+ `(no broadcast card; user-notice deferred to turn-end)`,
135
+ )
136
+ const allowFrom = deps.allowFrom()
137
+ if (allowFrom.length === 0) return
138
+ deps.scheduleUserNotice({
139
+ chatIds: [...allowFrom],
140
+ agent: event.agent,
141
+ kind: event.kind,
142
+ key: deps.liveTurnKey(),
143
+ atMs: now,
144
+ })
145
+ if (escalate) {
146
+ const card = renderTransportEscalationCard(event.agent)
147
+ deps.send(allowFrom[0], card.text, card.keyboard)
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Turn-end resolution of deferred user failure notices (#3293 finding 1, moved
153
+ * out of gateway.ts). `turnDeliveredReply` is `finalAnswerDelivered ||
154
+ * replyCalled` — a delivered reply DROPS the notices (turn recovered); a
155
+ * reply-less end flushes them (turn genuinely died). `turnKey` scopes resolution
156
+ * to the ending turn's topic under keyed liveness.
157
+ */
158
+ export function flushDeferredUserNotices(
159
+ turnDeliveredReply: boolean,
160
+ turnKey: string,
161
+ deps: UserFailureNoticeDeps,
162
+ ): void {
163
+ const notices = deps.resolveNotices(turnDeliveredReply, turnKey)
164
+ if (notices.length === 0) return
165
+ for (const notice of notices) {
166
+ deps.log(
167
+ `user-notice flush (turn died reply-less) agent=${notice.agent} ` +
168
+ `kind=${notice.kind} chats=${notice.chatIds.length}`,
169
+ )
170
+ for (const chatId of notice.chatIds) deps.send(chatId, notice.text)
171
+ }
172
+ }
@@ -373,6 +373,21 @@ export function verifyHistoryWritable(): { ok: boolean; error?: string } {
373
373
  }
374
374
  }
375
375
 
376
+ /**
377
+ * Read-only handle for the gateway boot-briefing builder
378
+ * (`gateway/boot-briefing-wiring.ts`). Exposes only the `prepare(...).all`
379
+ * subset the builder's `BriefingDb` seam needs; returns null before
380
+ * `initHistory` (or when history is disabled) so the briefing degrades to
381
+ * empty instead of throwing. Do NOT use for writes — every write path goes
382
+ * through the record* functions above so redaction and validity checks
383
+ * cannot be bypassed.
384
+ */
385
+ export function getHistoryDbForBriefing(): {
386
+ prepare(sql: string): { all(...params: unknown[]): unknown[] }
387
+ } | null {
388
+ return db
389
+ }
390
+
376
391
  /**
377
392
  * For tests — close the singleton and forget it. Production code never
378
393
  * needs this; the DB is held open for the lifetime of the process.