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.
- package/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9367 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +24734 -16371
- package/dist/host-control/main.js +59 -9
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +148 -2
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
- package/telegram-plugin/dist/server.js +7 -4
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +115 -203
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
- package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- 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
|
-
|
|
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)
|
|
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
|
/**
|