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
@@ -87,6 +87,7 @@ import {
87
87
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
88
88
  import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
89
89
  import { resolveReplyOwnerTurnWith, SUPERSEDE_OPEN_CAP_MS, SUPERSEDE_GRACE_MS } from './reply-owner-wiring.js'
90
+ import { getBuzzMirror, maybeBootBuzzMirror } from './buzz-mirror.js'
90
91
  import { subagentReplyAuthority } from './subagent-reply-authority.js'
91
92
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
92
93
  import {
@@ -410,6 +411,7 @@ import {
410
411
  import { createMcpFailureHook } from './mcp-failure-hook.js'
411
412
  import { pendingUserNoticeGate } from '../pending-user-notice.js'
412
413
  import { recordOperatorEvent } from '../operator-events-history.js'
414
+ import { emitTransportTransientEvent, flushDeferredUserNotices, type UserFailureNoticeDeps } from './user-failure-notices.js'
413
415
  import {
414
416
  parseLlmError,
415
417
  renderLlmErrorSafe,
@@ -960,6 +962,8 @@ import {
960
962
  buildResumeDeferredReportInbound,
961
963
  decideBootResumeKind,
962
964
  } from './resume-inbound-builder.js'
965
+ import { maybeQueueBootBriefing } from './boot-briefing-wiring.js'
966
+ import { writePendingTurnEnv } from './pending-turn-env.js'
963
967
  import {
964
968
  createBridgeDeadWatchdog,
965
969
  consumeBridgeDeadEscalationMarker,
@@ -2034,35 +2038,9 @@ if (isGatewayMain) try { // #2996 P0c: gated — opens bun:sqlite + writes .pen
2034
2038
 
2035
2039
  // Diagnostic env file (one-shot, sourced by start.sh) — kept for the
2036
2040
  // wake-audit context. The injected inbound above is the real wake signal;
2037
- // these vars are passive context only.
2038
- const pendingEnvPath = join(agentDir, '.pending-turn.env')
2039
- try {
2040
- if (pending != null) {
2041
- const lines = [
2042
- `SWITCHROOM_PENDING_TURN=true`,
2043
- `SWITCHROOM_PENDING_TURN_KEY=${pending.turn_key}`,
2044
- `SWITCHROOM_PENDING_CHAT_ID=${pending.chat_id}`,
2045
- pending.thread_id != null ? `SWITCHROOM_PENDING_THREAD_ID=${pending.thread_id}` : `SWITCHROOM_PENDING_THREAD_ID=`,
2046
- pending.last_user_msg_id != null ? `SWITCHROOM_PENDING_USER_MSG_ID=${pending.last_user_msg_id}` : `SWITCHROOM_PENDING_USER_MSG_ID=`,
2047
- `SWITCHROOM_PENDING_ENDED_VIA=${pending.ended_via ?? 'unknown'}`,
2048
- `SWITCHROOM_PENDING_STARTED_AT=${pending.started_at}`,
2049
- pending.interrupt_reason != null ? `SWITCHROOM_PENDING_INTERRUPT_REASON=${pending.interrupt_reason}` : `SWITCHROOM_PENDING_INTERRUPT_REASON=`,
2050
- ]
2051
- // Atomic write: tmp + rename. Without this, a crash mid-write
2052
- // (power loss, OOM, panic) leaves a truncated `.pending-turn.env`
2053
- // that start.sh `source`s — partial SWITCHROOM_PENDING_* vars
2054
- // or a malformed line break shell parsing inside the source.
2055
- const pendingEnvTmp = `${pendingEnvPath}.tmp-${process.pid}`
2056
- writeFileSync(pendingEnvTmp, lines.join('\n') + '\n', { mode: 0o600 })
2057
- renameSync(pendingEnvTmp, pendingEnvPath)
2058
- process.stderr.write(`telegram gateway: pending-turn env written to ${pendingEnvPath} turnKey=${pending.turn_key} endedVia=${pending.ended_via ?? 'open'}\n`)
2059
- } else if (existsSync(pendingEnvPath)) {
2060
- rmSync(pendingEnvPath, { force: true })
2061
- process.stderr.write(`telegram gateway: pending-turn env cleared (clean previous shutdown)\n`)
2062
- }
2063
- } catch (err) {
2064
- process.stderr.write(`telegram gateway: pending-turn env write failed (${(err as Error).message})\n`)
2065
- }
2041
+ // these vars are passive context only. Extracted to pending-turn-env.ts
2042
+ // (atomic tmp+rename writer; never throws).
2043
+ writePendingTurnEnv(agentDir, pending)
2066
2044
  } catch (err) {
2067
2045
  process.stderr.write(`telegram gateway: turn-registry init failed (${(err as Error).message}) — turn tracking disabled\n`)
2068
2046
  turnsDb = null
@@ -3690,7 +3668,7 @@ export type CurrentTurn = {
3690
3668
  // registry's older entries (and any hand-built test turn) tolerate its
3691
3669
  // absence; `emissionAuthorityFor` lazily backfills one when missing.
3692
3670
  emissionAuthority?: EmissionAuthority
3693
- }
3671
+ readonly originChannel: 'telegram' | 'buzz'; readonly buzzCoords?: { channelId: string; eventId: string; threadRoot: string } } // Buzz Phase 2a/2b: origin provenance stamped ONCE at the turn ctor via parseChannelOrigin(ev.rawContent) (channel-route.ts); buzzCoords present IFF originChannel==='buzz'. `readonly` enforces single-writer immutability (MINOR-2) — no `.originChannel=`/`.buzzCoords=` reassignment compiles. Types inlined + brace merged to hold gateway.ts at its zero-headroom ratchet (switchroom#2996); structurally identical to channel-route.ts Channel/BuzzCoords (type-identity asserted in channel-route.ts, MINOR-3).
3694
3672
 
3695
3673
  // PR-4e — the singleton `currentTurn` is RETAINED as (a) the flag-OFF store and
3696
3674
  // (b) the flag-ON "most-recent-set" MIRROR. Every GLOBAL-liveness read in this
@@ -7886,6 +7864,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7886
7864
  // 429 metrics — defense in depth, no secret survives in ANY downstream sink.
7887
7865
  event = { ...event, detail: redactOutboundText(event.detail, 'operator_event') }
7888
7866
 
7867
+ // transport-transient (mid-response stream abort — `server_error`/`api_error`
7868
+ // with no HTTP status): no broadcast card, no Reauth; record + deferred user
7869
+ // notice, burst escalates. Orchestration in user-failure-notices.ts.
7870
+ if (kind === 'transport-transient') {
7871
+ emitTransportTransientEvent(event, userFailureNoticeDeps())
7872
+ return
7873
+ }
7874
+
7889
7875
  // ── 429 throttle tier (operator spec: "retry in place under 5 min, else
7890
7876
  // mark + failover, honest reset messaging") ────────────────────────────
7891
7877
  // A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
@@ -8269,56 +8255,47 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
8269
8255
  // liveness only THAT topic's turn end resolves it (full rationale on
8270
8256
  // `PendingUserNotice.key`). `undefined` (no live turn — the event is
8271
8257
  // agent-level, empty wire chatId) keeps the legacy agent-wide resolution.
8272
- const liveTurn = currentTurn
8273
- const noticeKey =
8274
- liveTurn != null ? statusKey(liveTurn.sessionChatId, liveTurn.sessionThreadId) : undefined
8275
- pendingUserNoticeGate.schedule({
8276
- chatIds: userNoticeChats,
8277
- text: renderUserFacingFailureNotice(),
8278
- agent,
8279
- kind,
8280
- atMs: Date.now(),
8281
- key: noticeKey,
8282
- })
8258
+ const noticeDeps = userFailureNoticeDeps()
8259
+ const noticeKey = noticeDeps.liveTurnKey()
8260
+ noticeDeps.scheduleUserNotice({ chatIds: userNoticeChats, agent, kind, key: noticeKey, atMs: Date.now() })
8283
8261
  process.stderr.write(
8284
8262
  `telegram gateway: operator-event user-notice deferred to turn-end agent=${agent} kind=${kind} chats=${userNoticeChats.length} topic=${noticeKey ?? '-'}\n`,
8285
8263
  )
8286
8264
  }
8287
8265
  }
8288
8266
 
8267
+ /**
8268
+ * Live gateway deps for the plain user-failure-notice subsystem
8269
+ * (`user-failure-notices.ts`): transport-transient handling AND the turn-end
8270
+ * flush share this one wiring. Every side effect is a closure over live state.
8271
+ */
8272
+ function userFailureNoticeDeps(): UserFailureNoticeDeps {
8273
+ return {
8274
+ now: () => Date.now(),
8275
+ allowFrom: () => loadAccess().allowFrom,
8276
+ liveTurnKey: () => currentTurn != null ? statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId) : undefined,
8277
+ record: (e) => { try { recordOperatorEvent(e) } catch { /* history best-effort */ } },
8278
+ scheduleUserNotice: (i) => pendingUserNoticeGate.schedule({ ...i, text: renderUserFacingFailureNotice() }),
8279
+ resolveNotices: (delivered, key) => pendingUserNoticeGate.resolveTurnEnd(key, delivered),
8280
+ send: (chat_id, text, keyboard) => {
8281
+ const thread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: resolveAgentOutboundTopic({ kind: 'compact-watchdog' }), supergroupChatId: resolveAgentSupergroupChatId() })
8282
+ const opts = { ...(keyboard ? { reply_markup: keyboard } : {}), ...(thread != null ? { message_thread_id: thread } : {}) }
8283
+ // allow-raw-bot-api: user-failure-notice / transport escalation send; topic-aware opts
8284
+ void bot.api.sendRichMessage(chat_id, richMessage(text), opts as never).catch((e) => process.stderr.write(`telegram gateway: user-failure-notice send to ${chat_id} failed: ${e}\n`))
8285
+ },
8286
+ log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
8287
+ }
8288
+ }
8289
+
8289
8290
  /**
8290
8291
  * Turn-end resolution of deferred user failure notices (#3293 finding 1).
8291
8292
  * Called from `endCurrentTurnAtomic` — the ONE funnel every turn-end path
8292
8293
  * passes through. `turnDeliveredReply` is `finalAnswerDelivered || replyCalled`
8293
8294
  * (the model explicitly replied → the turn recovered → notices are dropped by
8294
- * the gate). Only a reply-less turn end flushes the pending notices to the
8295
- * non-operator chats, so the user notice fires IFF the turn genuinely died.
8296
- * `turnKey` (#3294) scopes resolution to the ending turn's topic — a concurrent
8297
- * topic's pending notice is left for its own turn end under keyed liveness.
8295
+ * the gate). The send loop lives in `user-failure-notices.ts`.
8298
8296
  */
8299
8297
  function flushPendingUserFailureNotices(turnDeliveredReply: boolean, turnKey: string): void {
8300
- const notices = pendingUserNoticeGate.resolveTurnEnd(turnKey, turnDeliveredReply)
8301
- if (notices.length === 0) return
8302
- const noticeTopic = resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
8303
- const noticeSupergroup = resolveAgentSupergroupChatId()
8304
- for (const notice of notices) {
8305
- process.stderr.write(
8306
- `telegram gateway: user-notice flush (turn died reply-less) agent=${notice.agent} kind=${notice.kind} chats=${notice.chatIds.length}\n`,
8307
- )
8308
- for (const chat_id of notice.chatIds) {
8309
- const thread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: noticeTopic, supergroupChatId: noticeSupergroup })
8310
- const opts = {
8311
- ...(thread != null ? { message_thread_id: thread } : {}),
8312
- }
8313
- // allow-raw-bot-api: deferred user-notice flush loop; topic-aware opts
8314
- void bot.api.sendRichMessage(chat_id, richMessage(notice.text), opts as never)
8315
- .catch(e => {
8316
- process.stderr.write(
8317
- `telegram gateway: user-notice send to ${chat_id} failed agent=${notice.agent} kind=${notice.kind}: ${e}\n`,
8318
- )
8319
- })
8320
- }
8321
- }
8298
+ flushDeferredUserNotices(turnDeliveredReply, turnKey, userFailureNoticeDeps())
8322
8299
  }
8323
8300
 
8324
8301
  /**
@@ -10196,6 +10173,22 @@ if (isGatewayMain && !STATIC && OBLIGATION_LEDGER_ENABLED) {
10196
10173
  setInterval(obligationSweep, OBLIGATION_SWEEP_MS).unref()
10197
10174
  }
10198
10175
 
10176
+ // Gateway boot briefing (session_continuity.briefing: gateway — default
10177
+ // legacy/off): a surface-scoped reorientation turn assembled from the durable
10178
+ // history DB and delivered as <channel source="boot_briefing"> over the spool
10179
+ // transport. Queued BEFORE the resume inbound below so a session that has
10180
+ // both reorients first, then resumes. All decision/build logic (flag,
10181
+ // --continue suppression, resume-window dedup, budget) lives in
10182
+ // boot-briefing-wiring.ts / boot-briefing-builder.ts; never throws.
10183
+ if (isGatewayMain && HISTORY_ENABLED) {
10184
+ maybeQueueBootBriefing({
10185
+ env: process.env,
10186
+ stateDir: STATE_DIR,
10187
+ resumeMsg: bootResumeInbound?.msg ?? null,
10188
+ put: (agent, msg) =>
10189
+ inboundSpool != null ? inboundSpool.put(agent, msg) : pendingInboundBuffer.push(agent, msg),
10190
+ })
10191
+ }
10199
10192
  // Honest-restart-resume: inject the boot resume/report inbound built by the
10200
10193
  // registry classifier above. When the spool exists we only PUT it (the
10201
10194
  // boot-replay loop below pulls it into the in-memory buffer exactly once via
@@ -11760,8 +11753,12 @@ if (isGatewayMain) ipcServer = createIpcServer({
11760
11753
  )
11761
11754
  },
11762
11755
 
11756
+ // Buzz Phase 2b: the duplex peer's advisory publish outcome — no-op unless the hub mirror booted.
11757
+ onBuzzPublishResult: (_c, m) => getBuzzMirror()?.onPublishResult(m),
11763
11758
  log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
11764
11759
  })
11760
+ // Buzz Phase 2b: boot the hub mirror (dark unless channels.buzz.enabled + mode both); wires the peer transport, else a no-op.
11761
+ if (isGatewayMain) maybeBootBuzzMirror((msg) => ipcServer.sendToBuzzPeer(msg))
11765
11762
 
11766
11763
  // ─── Webhook ingest server (RFC webhook-via-gateway-socket) ───────────────
11767
11764
  // Under the Docker runtime the host-side web receiver runs as the operator
@@ -12504,21 +12501,15 @@ async function executeReply(
12504
12501
  function gatewaySendReplyDeps(): SendReplyGatewayDeps {
12505
12502
  return {
12506
12503
  // the ONE live instances (Amendment 1/9 — never re-new in a module)
12507
- outboundDedup,
12508
- flushedTurnSupersede,
12509
- firstTextReplyLogged,
12510
- suppressPtyPreview,
12511
- activeDraftStreams,
12512
- lastPtyPreviewByChat,
12513
- voiceOnDemandCache,
12514
- voicePreSynthQueue,
12515
- pendingProgress,
12516
- signalTracker,
12504
+ outboundDedup, flushedTurnSupersede,
12505
+ firstTextReplyLogged, suppressPtyPreview,
12506
+ activeDraftStreams, lastPtyPreviewByChat,
12507
+ voiceOnDemandCache, voicePreSynthQueue,
12508
+ pendingProgress, signalTracker,
12517
12509
  silencePoke,
12518
12510
  getCurrentTurn: () => currentTurn,
12519
12511
  getLastActiveTurnChatId: () => lastActiveTurnChatId,
12520
- HISTORY_ENABLED,
12521
- TURN_ORIGIN_ROUTING_ENABLED,
12512
+ HISTORY_ENABLED, TURN_ORIGIN_ROUTING_ENABLED,
12522
12513
  AUTOCLASSIFY_MIDTURN_SHADOW,
12523
12514
  MAX_ATTACHMENT_BYTES,
12524
12515
  MAX_CHUNK_LIMIT,
@@ -12533,8 +12524,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
12533
12524
  statusKey,
12534
12525
  streamKey,
12535
12526
  resolveReplyOwnerTurn,
12536
- findTurnByOriginId,
12537
- findTurnByQuotedMessageId,
12527
+ findTurnByOriginId, findTurnByQuotedMessageId, findLatestTurnForChat,
12538
12528
  resolveAnswerThreadWithLog,
12539
12529
  resolveThreadId,
12540
12530
  getLatestInboundMessageId,
@@ -13282,6 +13272,8 @@ async function executeEditMessage(args: Record<string, unknown>): Promise<unknow
13282
13272
  process.stderr.write(`telegram gateway: history recordEdit failed: ${err}\n`)
13283
13273
  }
13284
13274
  }
13275
+ // Buzz Phase 2b: mirror the edit as a debounced Buzz correction keyed on the edited id — no-op when Buzz is dark or the message was never published; post-delivery, never throws.
13276
+ getBuzzMirror()?.mirrorCorrection({ telegramMessageKey: `${String(args.chat_id ?? '')}:${Number(args.message_id)}`, scrubbedText: editRawText })
13285
13277
  return { content: [{ type: 'text', text: `edited (id: ${id})` }] }
13286
13278
  }
13287
13279
 
@@ -96,6 +96,15 @@ export function spoolId(msg: InboundMessage): string {
96
96
  ) {
97
97
  return `s:resume:${msg.meta.resume_turn_key}`
98
98
  }
99
+ // Gateway boot briefing (session_continuity.briefing: gateway): keyed
100
+ // per chat, NOT per boot — the synthetic messageId is the boot's ts, so
101
+ // without this a multi-restart sequence would stack one briefing per
102
+ // boot. One live briefing per chat at a time; once delivered (acked) a
103
+ // later boot can mint a fresh one. Staleness is separately bounded by
104
+ // the entry's meta.expiresAt TTL (see liveEntries).
105
+ if (msg.meta?.source === 'boot_briefing') {
106
+ return `s:boot-briefing:${msg.chatId}`
107
+ }
99
108
  // Cron BOOT-REPLAY (#2793 part B): a scheduled fire that the boot
100
109
  // replay re-injects because it was missed across a restart. Keyed on
101
110
  // the minute-aligned fire it is replaying (`replay_fire_ms`) plus the
@@ -488,7 +497,30 @@ export function createInboundSpool(opts: InboundSpoolOptions): InboundSpool {
488
497
  return {
489
498
  put(agent, msg) {
490
499
  const id = spoolId(msg)
491
- if (live.has(id)) return false // dedup: already spooled & un-acked
500
+ const existing = live.get(id)
501
+ if (existing != null) {
502
+ // Dedup: the same logical event is already spooled and un-acked, so
503
+ // by default drop the duplicate (retried synthetics of one event).
504
+ //
505
+ // Exception (#4246): a boot_briefing is keyed per chat, NOT per boot
506
+ // (spoolId `s:boot-briefing:<chatId>`), so a LATER boot re-puts a
507
+ // FRESHER briefing under the same id. The strict-dedup path kept
508
+ // boot-1's now-stale text, so a boot-2 delivery carried boot-1's
509
+ // briefing (only self-corrected by the 60-min TTL). Refresh the
510
+ // entry's payload in place so the newest briefing wins, keeping the
511
+ // original firstAt so escalation timing isn't reset by repeated
512
+ // restarts. The refreshed put is re-appended durably (hydrate's
513
+ // "last put for an id wins" restores it across a crash). No new live
514
+ // entry is created, so this can never double-deliver.
515
+ if (msg.meta?.source === 'boot_briefing') {
516
+ existing.agent = agent
517
+ existing.msg = msg
518
+ appendRecord({ t: 'put', id, agent, msg, firstAt: existing.firstAt })
519
+ maybeCompact()
520
+ return true
521
+ }
522
+ return false
523
+ }
492
524
  const firstAt = now()
493
525
  live.set(id, { agent, msg, firstAt })
494
526
  appendRecord({ t: 'put', id, agent, msg, firstAt })
@@ -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;