switchroom 0.18.32 → 0.19.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 (121) hide show
  1. package/dist/auth-broker/index.js +17 -1
  2. package/dist/cli/switchroom.js +847 -729
  3. package/dist/host-control/main.js +18 -2
  4. package/dist/vault/approvals/kernel-server.js +17 -1
  5. package/dist/vault/broker/server.js +44 -2
  6. package/package.json +2 -2
  7. package/profiles/_base/start.sh.hbs +105 -18
  8. package/telegram-plugin/dist/gateway/gateway.js +60612 -56998
  9. package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
  10. package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
  11. package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
  12. package/telegram-plugin/gateway/boot-card.ts +169 -1
  13. package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
  14. package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
  15. package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
  16. package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
  17. package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
  18. package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
  19. package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
  20. package/telegram-plugin/gateway/gateway.ts +1347 -6758
  21. package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
  22. package/telegram-plugin/gateway/inbound-router.ts +400 -0
  23. package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
  24. package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
  25. package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
  26. package/telegram-plugin/gateway/model-command.ts +23 -0
  27. package/telegram-plugin/gateway/narrative-lane.ts +865 -0
  28. package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
  29. package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
  30. package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
  31. package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
  32. package/telegram-plugin/gateway/secret-request-card.ts +45 -0
  33. package/telegram-plugin/gateway/stream-render.ts +2166 -0
  34. package/telegram-plugin/gateway/turn-end.ts +606 -0
  35. package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
  36. package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
  37. package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
  38. package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
  39. package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
  40. package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
  41. package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
  42. package/telegram-plugin/narrative-dedup.ts +24 -1
  43. package/telegram-plugin/narrative-flush.ts +2 -2
  44. package/telegram-plugin/render/render.ts +25 -1
  45. package/telegram-plugin/status-no-truncate.ts +13 -0
  46. package/telegram-plugin/subagent-watcher.ts +186 -3
  47. package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
  48. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
  49. package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
  50. package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
  51. package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
  52. package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
  53. package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
  54. package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
  55. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +15 -6
  56. package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
  57. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +18 -9
  58. package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
  59. package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
  60. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
  61. package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
  62. package/telegram-plugin/tests/emission-authority-facade.test.ts +47 -10
  63. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +27 -9
  64. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
  65. package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +39 -18
  66. package/telegram-plugin/tests/gateway-boot-smoke.test.ts +160 -0
  67. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +3 -7
  68. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
  69. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +8 -2
  70. package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
  71. package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
  72. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
  73. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +14 -3
  74. package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
  75. package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
  76. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
  77. package/telegram-plugin/tests/model-command.test.ts +30 -0
  78. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +27 -9
  79. package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
  80. package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
  81. package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
  82. package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
  83. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +16 -7
  84. package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
  85. package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
  86. package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
  87. package/telegram-plugin/tests/render/render.test.ts +42 -0
  88. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
  89. package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
  90. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +28 -18
  91. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
  93. package/telegram-plugin/tests/stop-command.test.ts +22 -12
  94. package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
  96. package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +14 -0
  97. package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
  98. package/telegram-plugin/tests/turn-flush-safety.test.ts +183 -5
  99. package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +9 -4
  100. package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
  101. package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
  102. package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
  103. package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
  104. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
  105. package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
  106. package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
  107. package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
  108. package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
  109. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +110 -20
  110. package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
  111. package/telegram-plugin/tool-activity-summary.ts +83 -35
  112. package/telegram-plugin/turn-flush-safety.ts +80 -14
  113. package/telegram-plugin/uat/restart-capability.ts +76 -0
  114. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
  115. package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
  116. package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
  117. package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
  118. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
  119. package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
  120. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
  121. package/telegram-plugin/worker-activity-feed.ts +10 -4
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Inbound routing seam (switchroom#2996 P7).
3
+ *
4
+ * `routeInbound` is the single entry point the gateway's coalesced inbound
5
+ * surfaces (`bot.on('message:text')` and the terminal
6
+ * `installUnhandledMessageCatchAll`) delegate through. TODAY it is a pure
7
+ * indirection: it forwards straight to the existing `handleInboundCoalesced`
8
+ * via an injected `InboundRouterDeps` — behaviour-identical, zero control-flow
9
+ * change. It exists so the later P7 stages can (a) move the intercept gauntlet
10
+ * into `inbound-interceptors.ts` behind this same Deps boundary and (b) put the
11
+ * eventual v2 consolidated control flow behind a deterministic kill switch at
12
+ * one chokepoint, mirroring the P6 `MediaEnvelopeDeps` injection idiom.
13
+ *
14
+ * The Deps object holds LIVE references to the gateway's collaborators (never
15
+ * value snapshots), bound once in gateway.ts alongside `mediaEnvelopeDeps`.
16
+ */
17
+
18
+ import type { Context } from 'grammy'
19
+ import type { AttachmentMeta } from './gateway.js'
20
+ import {
21
+ interceptStopKeyword,
22
+ interceptInterruptMarker,
23
+ interceptPermissionReply,
24
+ interceptAuthAdd,
25
+ interceptLoopbackRelay,
26
+ interceptReauth,
27
+ interceptVault,
28
+ interceptSecretStagingCommand,
29
+ type InboundInterceptorDeps,
30
+ type InterceptOutcome,
31
+ type StopKeywordParams,
32
+ type InterruptMarkerParams,
33
+ type InterruptMarkerOutcome,
34
+ type AuthAddParams,
35
+ } from './inbound-interceptors.js'
36
+ import type { InboundMessage } from './ipc-protocol.js'
37
+ import { deriveTurnId } from './derive-turn-id.js'
38
+ import { formatReplyToText } from '../steering.js'
39
+ import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
40
+ import { safeResolvePersonName, type PersonDirectory } from './resolve-person.js'
41
+ import {
42
+ parseForwardOrigin,
43
+ dedupeForwardOrigins,
44
+ buildForwardOriginMeta,
45
+ forwardOriginDateIso,
46
+ type ForwardOriginInfo,
47
+ } from './forward-origin.js'
48
+
49
+ /**
50
+ * Collaborators `routeInbound` needs, injected from gateway.ts module scope so
51
+ * this module stays importable + unit-testable without gateway's boot side
52
+ * effects. Extended by later P7 stages as intercepts move across the boundary;
53
+ * every field is a live reference (function / Map / object), not a snapshot.
54
+ */
55
+ export interface InboundRouterDeps {
56
+ handleInboundCoalesced: (
57
+ ctx: Context,
58
+ text: string,
59
+ downloadImage: (() => Promise<string | undefined>) | undefined,
60
+ attachment?: AttachmentMeta,
61
+ ) => Promise<void>
62
+ }
63
+
64
+ /**
65
+ * Route a coalesced inbound message. Pure indirection in P7 PR-1 — forwards to
66
+ * `deps.handleInboundCoalesced` unchanged.
67
+ */
68
+ export async function routeInbound(
69
+ ctx: Context,
70
+ text: string,
71
+ downloadImage: (() => Promise<string | undefined>) | undefined,
72
+ attachment: AttachmentMeta | undefined,
73
+ deps: InboundRouterDeps,
74
+ ): Promise<void> {
75
+ return deps.handleInboundCoalesced(ctx, text, downloadImage, attachment)
76
+ }
77
+
78
+
79
+ // ─── P7 PR-10: admission head + pure envelope builders ─────────────────────
80
+
81
+ /** Admission decision facts injected from gateway.ts (all live refs / fixed
82
+ * values). `topicId` is the boot-fixed TOPIC_ID scoping; `gate` is the
83
+ * allowFrom access gate; `logGateDeny` the rate-limited deny logger. */
84
+ export interface InboundAdmissionDeps {
85
+ topicId: number | null
86
+ gate: (ctx: Context) =>
87
+ | { action: 'deliver'; access: unknown }
88
+ | { action: 'drop'; reason: unknown }
89
+ | { action: 'pair'; code: string; isResend: boolean }
90
+ logGateDeny: (ctx: Context, reason: never) => void
91
+ }
92
+
93
+ /** Outcome of admission: `admitted: false` — fully handled (topic-scoped out,
94
+ * gate-dropped, or pairing replied); `admitted: true` carries the gate's
95
+ * access payload for downstream person-resolution. */
96
+ export type InboundAdmissionResult =
97
+ | { admitted: false }
98
+ | { admitted: true; access: unknown }
99
+
100
+ /**
101
+ * Topic-scope + access-gate admission head of handleInbound, moved verbatim
102
+ * (#2996 P7 PR-10). Drop/pair early-return semantics unchanged: TOPIC_ID
103
+ * scoping silently ignores out-of-topic messages; gate 'drop' logs the deny;
104
+ * gate 'pair' replies with the pairing instruction.
105
+ */
106
+ export async function admitInbound(
107
+ ctx: Context,
108
+ deps: InboundAdmissionDeps,
109
+ ): Promise<InboundAdmissionResult> {
110
+ const isTopicMessage = ctx.message?.is_topic_message ?? false
111
+ const messageThreadId = ctx.message?.message_thread_id
112
+
113
+ if (deps.topicId != null) {
114
+ if (!isTopicMessage || messageThreadId !== deps.topicId) return { admitted: false }
115
+ }
116
+
117
+ const result = deps.gate(ctx)
118
+ if (result.action === 'drop') {
119
+ deps.logGateDeny(ctx, result.reason as never)
120
+ return { admitted: false }
121
+ }
122
+ if (result.action === 'pair') {
123
+ const lead = result.isResend ? 'Still pending' : 'Pairing required'
124
+ await ctx.reply(`${lead} — run in Claude Code:\n\n/telegram:access pair ${result.code}`)
125
+ return { admitted: false }
126
+ }
127
+ return { admitted: true, access: result.access }
128
+ }
129
+
130
+ /** Inputs for {@link buildReplyForwardContext} (pure). */
131
+ export interface ReplyForwardContextParams {
132
+ ctx: Context
133
+ coalescedForwardOrigins: ForwardOriginInfo[] | undefined
134
+ /** REPLY_TO_TEXT_MAX — fixed constant. */
135
+ replyToTextMax: number
136
+ }
137
+
138
+ /** Pure builder: Telegram-native reply context (#119) + server-stamped
139
+ * forward-origin context. Moved verbatim (#2996 P7 PR-10); raw-vs-escaped
140
+ * split preserved (raw for SQLite, escaped for channel meta). */
141
+ export function buildReplyForwardContext(p: ReplyForwardContextParams): {
142
+ replyToMessageId: number | undefined
143
+ replyToText: string | undefined
144
+ replyToTextEscaped: string | undefined
145
+ forwardOrigins: ForwardOriginInfo[]
146
+ forwardOriginMeta: Record<string, string>
147
+ primaryForwardOrigin: ForwardOriginInfo | undefined
148
+ } {
149
+ // Telegram-native reply context (issue #119). Same pattern as server.ts:
150
+ // `replyToText` is raw (for SQLite); `replyToTextEscaped` is XML-escaped
151
+ // (for channel meta).
152
+ const replyToMsg = p.ctx.message?.reply_to_message
153
+ const replyToMessageId = replyToMsg?.message_id
154
+ const replyToTextRaw = replyToMsg
155
+ ? (replyToMsg.text ?? replyToMsg.caption ?? undefined)
156
+ : undefined
157
+ const replyToText = replyToTextRaw != null
158
+ ? (replyToTextRaw.length > p.replyToTextMax
159
+ ? replyToTextRaw.slice(0, p.replyToTextMax - 1) + '…'
160
+ : replyToTextRaw)
161
+ : undefined
162
+ const replyToTextEscaped = formatReplyToText(replyToTextRaw, p.replyToTextMax)
163
+
164
+ // Forwarded-message origin context. `forward_origin` is stamped by
165
+ // Telegram's servers (Bot API 7.0+) — the forwarding user cannot forge
166
+ // it via the message body, so it rides the trusted attrs lane and is
167
+ // NEVER folded into the body text. Coalesced bursts pass their deduped
168
+ // per-entry origins; direct/bypass paths parse this p.ctx's own origin.
169
+ // Same raw-vs-escaped split as reply_to_text: `forwardOrigins` carries
170
+ // raw (truncated) names for SQLite, `buildForwardOriginMeta` escapes at
171
+ // the channel-meta boundary.
172
+ const forwardOrigins = p.coalescedForwardOrigins
173
+ ?? dedupeForwardOrigins([parseForwardOrigin(p.ctx.message?.forward_origin)])
174
+ const forwardOriginMeta = buildForwardOriginMeta(forwardOrigins)
175
+ const primaryForwardOrigin = forwardOrigins[0]
176
+ return {
177
+ replyToMessageId,
178
+ replyToText,
179
+ replyToTextEscaped,
180
+ forwardOrigins,
181
+ forwardOriginMeta,
182
+ primaryForwardOrigin,
183
+ }
184
+ }
185
+
186
+ /** Inputs for {@link buildInboundEnvelope} — every field is an at-call
187
+ * captured value (steering meta, at-receipt snapshots, resolved attachments),
188
+ * never a live getter. */
189
+ export interface EnvelopeBuildParams {
190
+ ctx: Context
191
+ chat_id: string
192
+ messageThreadId: number | undefined
193
+ msgId: number | undefined
194
+ effectiveText: string
195
+ imagePath: string | undefined
196
+ attachment: AttachmentMeta | undefined
197
+ attachmentCount: number
198
+ extraMeta: Record<string, string>
199
+ from: { id: number; username?: string }
200
+ access: { groups: Record<string, { allowFrom?: string[] } | undefined> }
201
+ isSteering: boolean
202
+ isQueuedPrefix: boolean
203
+ isQueuedMidTurn: boolean
204
+ priorTurnInProgress: boolean
205
+ secondsSinceTurnStart: number | undefined
206
+ priorAssistantPreview: string | undefined
207
+ replyToMessageId: number | undefined
208
+ replyToTextEscaped: string | undefined
209
+ forwardOriginMeta: Record<string, string>
210
+ /** TOPIC_FRAMING_ENABLED — fixed constant. */
211
+ topicFramingEnabled: boolean
212
+ /** Captured at call time; the gateway's PERSON_DIRECTORY is a boot-assigned
213
+ * mutable let, so the value crosses per message. */
214
+ personDirectory: PersonDirectory
215
+ isDmChatId: (chatId: string | null | undefined) => boolean
216
+ }
217
+
218
+ /**
219
+ * Pure builder for the ipc-protocol InboundMessage envelope (meta lane +
220
+ * attachment fields + steering/queued flags + origin-turn-id + topic-scope
221
+ * directive). Moved verbatim from handleInbound (#2996 P7 PR-10); the machine
222
+ * emit and deliver-or-buffer dispatch stay in gateway.ts, wired exactly as
223
+ * before.
224
+ */
225
+ export function buildInboundEnvelope(p: EnvelopeBuildParams): InboundMessage {
226
+ // Dispatch to connected bridge via IPC
227
+ // Component 3 — stable origin turn id stamped into the meta the model
228
+ // reads, so a reply can echo it back (origin_turn_id) and the gateway
229
+ // can route the answer to the turn that owns it even after currentTurn
230
+ // flips. Derived from chat/thread/messageId, matching the turnId the
231
+ // enqueue handler computes for the turn this inbound starts.
232
+ const originTurnId = deriveTurnId(p.chat_id, p.messageThreadId ?? null, p.msgId)
233
+ // Component 4 — per-turn topic framing. In a forum supergroup a queued
234
+ // cross-topic message could tempt the model to also answer a pending
235
+ // question from another topic. A one-line directive (only for topic
236
+ // inbounds, only when framing is enabled) pins the model to THIS
237
+ // message's topic. DMs / non-topic chats get nothing.
238
+ const topicScope =
239
+ p.topicFramingEnabled && p.messageThreadId != null
240
+ ? 'This message belongs to the current topic only — answer ONLY this question, in this topic. Do not also answer a pending message from another topic.'
241
+ : undefined
242
+ // person_id name resolution (docs/configuration.md, resolve-person.ts):
243
+ // chat-scoped, boot-time-static lookup — falls back to today's raw
244
+ // id/username behavior (fail-open) whenever unresolved or when group
245
+ // membership can't be positively confirmed from p.access.json's allowFrom.
246
+ // Never touches p.access.json itself and never blocks/denies anything.
247
+ const rawUser = p.from.username ?? String(p.from.id)
248
+ const displayUser = safeResolvePersonName(
249
+ p.personDirectory,
250
+ {
251
+ telegramId: String(p.from.id),
252
+ username: p.from.username,
253
+ isDm: p.isDmChatId(p.chat_id),
254
+ groupAllowFrom: p.access.groups[p.chat_id]?.allowFrom,
255
+ },
256
+ rawUser,
257
+ )
258
+ const inboundMsg: InboundMessage = {
259
+ type: 'inbound',
260
+ chatId: p.chat_id,
261
+ ...(p.messageThreadId != null ? { threadId: p.messageThreadId } : {}),
262
+ messageId: p.msgId ?? 0,
263
+ user: displayUser,
264
+ userId: p.from.id,
265
+ ts: p.ctx.message?.date ?? Math.floor(Date.now() / 1000),
266
+ text: p.effectiveText,
267
+ ...(p.imagePath ? { imagePath: p.imagePath } : {}),
268
+ ...(p.attachment ? {
269
+ attachment: {
270
+ fileId: p.attachment.file_id,
271
+ mimeType: p.attachment.mime ?? 'application/octet-stream',
272
+ ...(p.attachment.name ? { fileName: p.attachment.name } : {}),
273
+ },
274
+ } : {}),
275
+ meta: {
276
+ chat_id: p.chat_id,
277
+ ...(p.msgId != null ? { message_id: String(p.msgId) } : {}),
278
+ user: displayUser,
279
+ user_id: String(p.from.id),
280
+ // Model-facing `ts="…"` on the inbound <channel> tag. Rendered as the
281
+ // agent's LOCAL am/pm wall-clock (NOT UTC ISO) so the model never reads
282
+ // a competing UTC "now" — the numeric epoch survives on InboundMessage.ts
283
+ // (above) and in the SQLite history for any machine consumer.
284
+ ts: fmtLocalStamp((p.ctx.message?.date ?? 0) * 1000, resolveEnvTimezone()),
285
+ ...(p.messageThreadId != null ? { message_thread_id: String(p.messageThreadId) } : {}),
286
+ // Component 3 — origin turn id. The model is told to pass this back
287
+ // as origin_turn_id on the reply so the answer routes to the topic
288
+ // this message came from (turn-origin routing). The reply tool also
289
+ // resolves it from the live/recent turn registry, so a model that
290
+ // omits it still routes correctly via the live-turn fallback.
291
+ ...(originTurnId != null ? { origin_turn_id: originTurnId } : {}),
292
+ // Component 4 — per-turn topic-scope directive (supergroup topics).
293
+ ...(topicScope != null ? { topic_scope: topicScope } : {}),
294
+ ...(p.imagePath ? { image_path: p.imagePath } : {}),
295
+ // Telegram-native reply context (issue #119). When set, the user
296
+ // long-pressed a prior message and chose "Reply" — the agent should
297
+ // treat this as the antecedent for "this" / "that" / pronoun
298
+ // references in the body, instead of asking the user what they meant.
299
+ ...(p.replyToMessageId != null ? { reply_to_message_id: String(p.replyToMessageId) } : {}),
300
+ // Use the XML-escaped form for the meta — the raw form is in the
301
+ // SQLite buffer for verbatim retrieval via get_recent_messages.
302
+ ...(p.replyToTextEscaped != null && p.replyToTextEscaped.length > 0 ? { reply_to_text: p.replyToTextEscaped } : {}),
303
+ // Forwarded-message origin (server-stamped, attrs-only — see above).
304
+ // forwarded_from / forwarded_from_type / forwarded_from_id /
305
+ // forwarded_date, plus numbered _2.. siblings for a multi-origin
306
+ // burst. Names are XML-escaped inside buildForwardOriginMeta.
307
+ ...p.forwardOriginMeta,
308
+ // queued="true" when mid-turn with no steer prefix (new default), or
309
+ // with explicit /queue or /q prefix (legacy alias).
310
+ ...((p.isQueuedMidTurn || p.isQueuedPrefix) ? { queued: 'true' } : {}),
311
+ // steering="true" only when explicit /steer or /s prefix used.
312
+ ...(p.isSteering ? { steering: 'true' } : {}),
313
+ ...(p.priorTurnInProgress ? { prior_turn_in_progress: 'true' } : {}),
314
+ ...(p.priorTurnInProgress && p.secondsSinceTurnStart != null ? { seconds_since_turn_start: String(p.secondsSinceTurnStart) } : {}),
315
+ ...(p.priorTurnInProgress && p.priorAssistantPreview != null && p.priorAssistantPreview.length > 0 ? { prior_assistant_preview: p.priorAssistantPreview } : {}),
316
+ ...(p.attachment ? {
317
+ attachment_kind: p.attachment.kind,
318
+ attachment_file_id: p.attachment.file_id,
319
+ ...(p.attachment.size != null ? { attachment_size: String(p.attachment.size) } : {}),
320
+ ...(p.attachment.mime ? { attachment_mime: p.attachment.mime } : {}),
321
+ ...(p.attachment.name ? { attachment_name: p.attachment.name } : {}),
322
+ } : {}),
323
+ // A2: numbered fields for the 2nd..Nth p.attachment + a total count so
324
+ // the agent reads every item in a coalesced multi-media burst.
325
+ ...(p.attachmentCount > 1 ? { attachment_count: String(p.attachmentCount) } : {}),
326
+ ...p.extraMeta,
327
+ },
328
+ }
329
+ return inboundMsg
330
+ }
331
+
332
+
333
+ // ─── P7 PR-11: kill-switched v2 intercept chain ────────────────────────────
334
+
335
+ /**
336
+ * THE deterministic cutover switch (#2996 P7 PR-11, design §2). Read ONCE at
337
+ * module load (F2: flags are const-captured — flipping requires an agent
338
+ * restart, same as every other `_ENABLED` flag). Default ON (canary-validated
339
+ * on v0.18.33 — 5/5 UAT, DM + supergroup): the v2 consolidated intercept chain
340
+ * is the production control flow. The v2 chain and the legacy sequence call the
341
+ * SAME extracted intercept functions in the SAME order — the characterization
342
+ * harness is the parity oracle (F6; the #3316-removed gate-parity probe is not
343
+ * relied on), and tests/inbound-router-v2-chain.test.ts pins the v2 path with
344
+ * the flag set env-before-import.
345
+ *
346
+ * Rollback (escape hatch): `SWITCHROOM_INBOUND_ROUTER_V2=0` + agent restart.
347
+ * No model involvement.
348
+ */
349
+ export const INBOUND_ROUTER_V2 = process.env.SWITCHROOM_INBOUND_ROUTER_V2 !== '0'
350
+
351
+ /** Outcome of the pre-turn chain: stop-keyword then interrupt-marker. */
352
+ export type PreTurnChainOutcome = InterruptMarkerOutcome
353
+
354
+ /**
355
+ * v2 chain, segment 1 — the pre-turn kill switches, in load-bearing order:
356
+ * stop-keyword (#3020) THEN interrupt-marker (#575). Between this chain and
357
+ * the paste-back chain the caller runs the stay-behind status-KPI telemetry
358
+ * block (F3 — non-intercepting, position preserved).
359
+ */
360
+ export async function runPreTurnIntercepts(
361
+ p: { stop: StopKeywordParams; interrupt: InterruptMarkerParams },
362
+ deps: InboundInterceptorDeps,
363
+ ): Promise<PreTurnChainOutcome> {
364
+ const stopOutcome = await interceptStopKeyword(p.stop, deps)
365
+ if (stopOutcome.handled) return { handled: true }
366
+ return interceptInterruptMarker(p.interrupt, deps)
367
+ }
368
+
369
+ /**
370
+ * v2 chain, segment 2 — the reply/paste-back gauntlet, in load-bearing order:
371
+ * permission-reply → auth-add → loopback-relay (incl. #3084 fail-safe) →
372
+ * reauth → vault → secret-staging command. Auth-add-before-reauth and
373
+ * fail-safe-before-reauth orderings are enforced structurally here (the
374
+ * design's PR-11 goal: ordering by code, not by comment).
375
+ */
376
+ export async function runPasteBackIntercepts(
377
+ p: AuthAddParams,
378
+ deps: InboundInterceptorDeps,
379
+ ): Promise<InterceptOutcome> {
380
+ const perm = interceptPermissionReply(
381
+ { text: p.text, chat_id: p.chat_id, msgId: p.msgId },
382
+ deps,
383
+ )
384
+ if (perm.handled) return perm
385
+ const authAdd = await interceptAuthAdd(p, deps)
386
+ if (authAdd.handled) return authAdd
387
+ const loopback = await interceptLoopbackRelay(p, deps)
388
+ if (loopback.handled) return loopback
389
+ const reauth = await interceptReauth(p, deps)
390
+ if (reauth.handled) return reauth
391
+ const vault = await interceptVault(
392
+ { ctx: p.ctx, text: p.text, chat_id: p.chat_id, msgId: p.msgId },
393
+ deps,
394
+ )
395
+ if (vault.handled) return vault
396
+ return interceptSecretStagingCommand(
397
+ { ctx: p.ctx, text: p.text, chat_id: p.chat_id, msgId: p.msgId },
398
+ deps,
399
+ )
400
+ }