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,333 @@
1
+ /**
2
+ * obligation-wiring.ts — obligation open/close/cancel + the idle sweep
3
+ * (#2996 P8 PR-C2).
4
+ *
5
+ * Extracted VERBATIM from gateway.ts: `closeObligationOnSubstantiveReply`,
6
+ * `openObligationFromInbound`, `cancelInterruptedObligation` and
7
+ * `obligationSweep` (idle-only re-present / escalate driver). STATE stays in
8
+ * gateway.ts — the `ObligationLedger` instance + durable store,
9
+ * `pendingCrossTurnGate`, `obligationEscalateInFlight`, the grace consts, and
10
+ * the escalation NUDGE SEND (the one bot-api touch, kept behind the gateway's
11
+ * retry-policy allowlist and injected as `sendEscalationNudge`). The sweep
12
+ * `setInterval` stays gateway-owned (P0c: modules export the tick body).
13
+ *
14
+ * Never-storm invariants preserved by construction: grace consts injected
15
+ * verbatim; `obligationEscalateInFlight` crosses as the SAME shared Set
16
+ * reference; the represent/escalate guards are the same imported pure modules.
17
+ */
18
+ import type { CurrentTurn, ObligationWiringDeps } from './gateway.js'
19
+ import type { InboundMessage } from './ipc-protocol.js'
20
+ import { shouldTrackDelivery } from './inbound-delivery-confirm.js'
21
+ import { deriveTurnId } from './derive-turn-id.js'
22
+ import { buildObligationRepresentInbound, type Obligation } from './obligation-ledger.js'
23
+ import { driveEscalation } from './escalation-drive.js'
24
+ import { shouldSuppressRepresent } from './represent-guard.js'
25
+ import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
26
+
27
+ export function createObligationWiring(deps: ObligationWiringDeps) {
28
+ const {
29
+ OBLIGATION_LEDGER_ENABLED,
30
+ HISTORY_ENABLED,
31
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS,
32
+ OBLIGATION_ESCALATE_GRACE_MS,
33
+ OBLIGATION_REPRESENT_GRACE_MS,
34
+ OBLIGATION_REPRESENT_MAX,
35
+ OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS,
36
+ OBLIGATION_ESCALATE_MAX,
37
+ OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
38
+ obligationLedger,
39
+ obligationEscalateInFlight,
40
+ pendingCrossTurnGate,
41
+ pendingInboundBuffer,
42
+ capturedResume,
43
+ getCurrentTurn,
44
+ turnInFlightForGate,
45
+ agentHasInFlightBackgroundWork,
46
+ hasOutboundDeliveredSince,
47
+ findTurnByOriginId,
48
+ bridgeAlive,
49
+ sendEscalationNudge,
50
+ } = deps
51
+
52
+ /**
53
+ * PR2 obligation-ledger CLOSE. Called when a SUBSTANTIVE final answer lands
54
+ * (not a bare interim ack — using finalAnswerSubstantive, the #2141 signal): the
55
+ * obligation discharged is the one for the SAME origin the answer routes to
56
+ * (origin_turn_id the model echoed, else the routed origin the gateway resolved,
57
+ * else the live turn). So 713's reply closes 713's obligation even after
58
+ * currentTurn flipped to 715, and 715 stays open until ITS own substantive
59
+ * answer. Answers to re-presented obligations (via=quoted, no model echo) close
60
+ * via the gateway-resolved routedOriginTurn. An ack does NOT close (so
61
+ * ack-then-ghost is re-presented, not re-dropped). The live-turn fallback fires
62
+ * only for the live turn's OWN obligation (it was the turn delivering this
63
+ * reply), preserving the 713/715 invariant. No-op unless the flag is on.
64
+ *
65
+ * @param routedOriginTurn — the origin the reply router already resolved
66
+ * (echoedTurn ?? quotedTurn); pass whenever the TURN_ORIGIN_ROUTING path ran.
67
+ * Skipped when null/undefined (pre-routing paths, or DM with no quote).
68
+ */
69
+ function closeObligationOnSubstantiveReply(
70
+ args: Record<string, unknown>,
71
+ liveTurn: CurrentTurn | null | undefined,
72
+ routedOriginTurn?: CurrentTurn | null,
73
+ ): void {
74
+ if (!OBLIGATION_LEDGER_ENABLED) return
75
+ const echoed = findTurnByOriginId(args.origin_turn_id as string | undefined)
76
+ // routedOriginTurn is the gateway-resolved origin (echoedTurn ?? quotedTurn).
77
+ // Only pass it as routedOriginId when it DIFFERS from the echoed turn (if
78
+ // echoed is present, resolveCloseTarget's first branch already handles it),
79
+ // and only when it is NOT the live turn (live-turn is the fallback, not the
80
+ // routed origin — passing live turn here would bypass the live-turn fallback
81
+ // logic and still close correctly, but naming matters for the 713/715 case:
82
+ // the routed origin on a via=quoted reply IS the origin, not "live fallback").
83
+ const routedOriginId =
84
+ routedOriginTurn != null && echoed == null
85
+ ? routedOriginTurn.turnId
86
+ : null
87
+ const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId, routedOriginId)
88
+ if (target != null) obligationLedger.close(target)
89
+ }
90
+
91
+ /**
92
+ * PR2 obligation-ledger OPEN. Track a fresh user inbound as an unanswered
93
+ * obligation the moment it is received — called BEFORE the buffer-until-idle /
94
+ * deliver split so a mid-turn cross-topic inbound (the 715 case: buffered while
95
+ * another turn runs) is tracked too. Drain paths re-deliver buffered inbounds
96
+ * via sendToAgent (NOT through handleInbound), so handleInbound is the ONLY
97
+ * point that sees every fresh inbound — opening here is mandatory for both
98
+ * branches. Same gate as delivery-tracking (real user turns only; synthetic /
99
+ * steering / `!` interrupt / empty excluded — they have no origin id / need no
100
+ * answer; deriveTurnId null-guards them). Idempotent → opening at buffer-time
101
+ * and any later delivery is safe. No-op unless the flag is on.
102
+ */
103
+ function openObligationFromInbound(
104
+ inboundMsg: InboundMessage,
105
+ gate: { isSteering: boolean; isInterrupt: boolean; effectiveText: string },
106
+ ): void {
107
+ if (!OBLIGATION_LEDGER_ENABLED) return
108
+ if (
109
+ !shouldTrackDelivery({
110
+ isSteering: gate.isSteering,
111
+ isInterrupt: gate.isInterrupt,
112
+ hasSource: inboundMsg.meta?.source != null,
113
+ effectiveText: gate.effectiveText,
114
+ })
115
+ ) {
116
+ return
117
+ }
118
+ const oid = deriveTurnId(inboundMsg.chatId, inboundMsg.threadId, inboundMsg.messageId)
119
+ if (oid == null) return
120
+ obligationLedger.openIfAbsent({
121
+ originTurnId: oid,
122
+ chatId: inboundMsg.chatId,
123
+ threadId: inboundMsg.threadId,
124
+ messageId: inboundMsg.messageId,
125
+ text: inboundMsg.text ?? '',
126
+ openedAt: Date.now(),
127
+ })
128
+ }
129
+
130
+ /**
131
+ * An `!` interrupt SIGINT-kills the in-flight turn. That turn was handling a
132
+ * user message with an open obligation, and the killed turn does NOT reliably
133
+ * emit turn_end (so endCurrentTurnAtomic never closes it) — so without this the
134
+ * obligation survives and the idle sweep later re-presents/escalates "you have
135
+ * an earlier message you never answered" for a question the user EXPLICITLY
136
+ * cancelled. An interrupt is a deliberate redirect, so closing that obligation
137
+ * is the correct terminal (the user chose to interrupt; they can re-ask). Only
138
+ * the interrupted turn's OWN obligation is closed — queued siblings (other open
139
+ * obligations) are untouched. No-op when the flag is off, no turn is in flight,
140
+ * or the turn isn't a tracked obligation (synthetic / already closed).
141
+ */
142
+ function cancelInterruptedObligation(): void {
143
+ if (!OBLIGATION_LEDGER_ENABLED) return
144
+ const turn = getCurrentTurn()
145
+ if (turn == null) return
146
+ if (obligationLedger.close(turn.turnId)) {
147
+ process.stderr.write(
148
+ `telegram gateway: obligation cancelled by interrupt origin=${turn.turnId}\n`,
149
+ )
150
+ }
151
+ }
152
+
153
+
154
+ // Throttle for the background-work defer diagnostic (the 5s sweep would otherwise
155
+ // log every tick across a multi-minute research window).
156
+ let lastBgWorkDeferLogMs = 0
157
+
158
+ function obligationSweep(): void {
159
+ if (!OBLIGATION_LEDGER_ENABLED) return
160
+ if (!obligationLedger.hasOpen()) return
161
+ if (turnInFlightForGate()) return // a turn is running — let it finish/answer
162
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
163
+ const now = Date.now()
164
+ // Background-work grace: while genuine autonomous sub-agent work is in flight
165
+ // (a running worker, or an orphaned foreground sub-agent — neither visible to
166
+ // the turn machine), an obligation younger than the ceiling is NOT re-presented
167
+ // /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS so escalation
168
+ // always eventually fires. =0 disables it.
169
+ const backgroundWorkActive =
170
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
171
+ // Grace window: skip an obligation whose handling turn ended < grace ago — its
172
+ // trailing slow/worker answer may still be landing (over-escalation fix).
173
+ // Per-represent grace: skip an obligation re-presented < grace ago — prevents
174
+ // the 5s sweep from immediately firing again before the re-present even lands.
175
+ const decision = obligationLedger.decideAtIdle(
176
+ OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive || OBLIGATION_REPRESENT_GRACE_MS > 0
177
+ ? {
178
+ now,
179
+ graceMs: OBLIGATION_ESCALATE_GRACE_MS,
180
+ backgroundWorkActive,
181
+ backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
182
+ representGraceMs: OBLIGATION_REPRESENT_GRACE_MS,
183
+ }
184
+ : undefined,
185
+ )
186
+ const o = decision.obligation
187
+ if (decision.action === 'none' || o == null) {
188
+ if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60_000) {
189
+ lastBgWorkDeferLogMs = now
190
+ process.stderr.write(
191
+ `telegram gateway: obligation sweep deferred — in-flight autonomous sub-agent work ` +
192
+ `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
193
+ )
194
+ }
195
+ return
196
+ }
197
+ if (decision.action === 'represent') {
198
+ // Fix #2472 — duplicate-represent guard. Before re-presenting AGAIN, check
199
+ // whether the agent has ALREADY delivered a substantive outbound reply to
200
+ // this chat SINCE the obligation was most recently re-presented. If so the
201
+ // obligation is satisfied-but-misdetected (the reply landed but its routing
202
+ // didn't resolve back to this origin, so the normal close path missed it) —
203
+ // close silently and do NOT re-fire, which is what produced the near-identical
204
+ // duplicate in #2472 (reply 10608 answered represent_count=1, yet
205
+ // represent_count=2 fired anyway → duplicate 10609).
206
+ //
207
+ // The cutoff is `lastRepresentedAt` (the time of the PREVIOUS represent), NOT
208
+ // `openedAt`. This is load-bearing: the genuine "agent wrote a plain-text
209
+ // answer and never called reply" case must still represent ONCE. On the first
210
+ // represent `lastRepresentedAt` is undefined, so this guard is a no-op and the
211
+ // single represent fires as before. Only the SECOND-and-later represent is
212
+ // gated — exactly where a reply that landed between fires must suppress the
213
+ // re-ask. Falls back to false (never suppresses) if history is unavailable.
214
+ if (
215
+ shouldSuppressRepresent(o, {
216
+ historyEnabled: HISTORY_ENABLED,
217
+ // Pass the represent-guard's OWN low threshold — a terse-but-real reply
218
+ // must suppress the duplicate (#2472/#2474), unlike the escalate branch
219
+ // below which keeps the 200-char default.
220
+ hasOutboundDeliveredSince: (chatId, sinceMs, threadId) =>
221
+ hasOutboundDeliveredSince(
222
+ chatId,
223
+ sinceMs,
224
+ threadId,
225
+ OBLIGATION_REPRESENT_GUARD_MIN_REPLY_CHARS,
226
+ ),
227
+ })
228
+ ) {
229
+ process.stderr.write(
230
+ `telegram gateway: obligation closed silently — reply delivered since last represent (no re-fire) origin=${o.originTurnId}\n`,
231
+ )
232
+ obligationLedger.close(o.originTurnId)
233
+ return
234
+ }
235
+ // #3282 — source-aware represent: a captured snapshot ⇒ resume the non-confirmed tail byte-identically; no snapshot ⇒ the fresh-generation represent below.
236
+ if (o.capturedDelivery != null) { capturedResume.dispatch(o); return }
237
+ // Re-present goes through the bridge → buffer. Only the represent path is
238
+ // gated on an empty buffer (let the existing drain run first, avoid
239
+ // double-presenting). Escalation below is NOT gated on the buffer — it is a
240
+ // direct Telegram send, independent of the bridge, so a represent stranded
241
+ // behind a dead bridge can never block the operator nudge.
242
+ if (pendingInboundBuffer.depth(agent) > 0) return
243
+ pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()))
244
+ // PR1 (cross-turn stale-card guard, §9 lever 4 / race C/D). Arm the
245
+ // card-OPEN gate for the synthetic turn this represent inbound will spawn:
246
+ // carry the obligation's `openedAt` so that turn's first card-OPEN can ask
247
+ // "was a substantive answer already delivered since the obligation was
248
+ // raised?" and, if so, suppress the card (it would otherwise narrate beneath
249
+ // the answer the user already received). Keyed on the obligation's
250
+ // `originTurnId` — the SAME id the represent inbound carries
251
+ // (`buildObligationRepresentInbound` reuses `o.messageId`/`o.chatId`/
252
+ // `o.threadId`, so the enqueue-time `deriveTurnId` reconstructs exactly
253
+ // `o.originTurnId`). Keying on the turn id (not chat/thread) means ONLY the
254
+ // exact represent turn this gate was armed for can consume it; an unrelated
255
+ // later foreground turn on the same chat/thread has a different originTurnId
256
+ // → finds no entry → its card opens normally. This closes the residual
257
+ // cross-contamination window where a never-enqueued represent's stale gate
258
+ // could suppress an unrelated turn's card (the represent/duplicate-reply
259
+ // family). This does NOT gate the represent SEND — the represent guard above
260
+ // already owns suppressing an already-satisfied represent; this only governs
261
+ // the decorative card.
262
+ pendingCrossTurnGate.set(o.originTurnId, { sinceMs: o.openedAt })
263
+ const attempt = obligationLedger.markRepresented(o.originTurnId)
264
+ process.stderr.write(
265
+ `telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt}/${OBLIGATION_REPRESENT_MAX}\n`,
266
+ )
267
+ return
268
+ }
269
+ // escalate — re-present ladder exhausted. Before sending the user-visible
270
+ // apology, check whether the agent has ALREADY delivered an outbound reply
271
+ // to this chat since the obligation was opened. If yes, the obligation is
272
+ // stale (the agent did answer, just without closing the obligation via the
273
+ // normal close path) — close silently instead of alarming the user with a
274
+ // false "I may have missed this". This is Fix 4: escalate only on knowledge,
275
+ // not doubt. Fall back to false (safe: never suppresses) if history unavailable.
276
+ //
277
+ // #2788 Gap A — bridge-flap gate. The escalate branch DIRECT-SENDS (via
278
+ // bot.api.sendMessage below), bypassing the bridge. So while the bridge is
279
+ // down the represent branch is naturally stranded (bridge → buffer) but this
280
+ // branch would still fire a false "I may have missed this", even though the
281
+ // real reply is merely queued behind a transient outage. Defer: if the bridge
282
+ // is not alive, leave the obligation OPEN and re-drive on a later sweep once
283
+ // it recovers. Obligations survive bridge death (durable ledger, re-evaluated
284
+ // every sweep), so this deferral adds NO unbounded liveness dependency — the
285
+ // whole gateway already rests on the bridge eventually reconnecting.
286
+ if (shouldDeferEscalationForBridge({ bridgeAlive: bridgeAlive(agent) })) {
287
+ process.stderr.write(
288
+ `telegram gateway: obligation escalation deferred — bridge down (nudge waits for reconnect) origin=${o.originTurnId}\n`,
289
+ )
290
+ return
291
+ }
292
+ if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
293
+ process.stderr.write(
294
+ `telegram gateway: obligation closed silently — outbound delivered since open origin=${o.originTurnId}\n`,
295
+ )
296
+ obligationLedger.close(o.originTurnId)
297
+ return
298
+ }
299
+ // Proceed with escalation: send ONE operator-visible nudge and close the
300
+ // obligation ONLY AFTER it actually lands. This inverts the old
301
+ // close-before-send (which silently dropped the terminal whenever the send
302
+ // failed): the close is now itself an observable terminal. A transient send
303
+ // failure leaves the obligation OPEN → retried next sweep; a PERMANENT one
304
+ // (dead topic even after thread-fallback, blocked bot) is bounded by
305
+ // OBLIGATION_ESCALATE_MAX → close best-effort (the user is unreachable, so a
306
+ // bounded give-up beats an infinite loop / a boot-surviving poison record).
307
+ // Drive one escalation attempt. The send is a direct Telegram nudge
308
+ // (retryWithThreadFallback: a stale/renumbered topic → THREAD_NOT_FOUND retries
309
+ // thread-less, the #2096 pattern). driveEscalation guards against concurrent
310
+ // sends, bounds the send with withDeadline (so a hung send can't leak the
311
+ // in-flight flag and wedge the obligation OPEN), closes only after a successful
312
+ // send, and bounds permanent failures to a best-effort close. Extracted so the
313
+ // hang → bounded → terminal path is executable in escalation-drive.test.ts —
314
+ // the path neither mtcute (can't hang Telegram) nor a synchronous test reaches.
315
+ void driveEscalation({
316
+ escId: o.originTurnId,
317
+ inFlight: obligationEscalateInFlight,
318
+ ledger: obligationLedger,
319
+ // The nudge send stays in gateway.ts (it owns the bot handle + the
320
+ // bot-api retry-policy allowlist); this module only decides WHEN it fires.
321
+ send: () => sendEscalationNudge(o),
322
+ maxAttempts: OBLIGATION_ESCALATE_MAX,
323
+ deadlineMs: OBLIGATION_ESCALATE_SEND_DEADLINE_MS,
324
+ })
325
+ }
326
+
327
+ return {
328
+ closeObligationOnSubstantiveReply,
329
+ openObligationFromInbound,
330
+ cancelInterruptedObligation,
331
+ obligationSweep,
332
+ }
333
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Photo inbound handler (switchroom#2996 P6 cluster D).
3
+ *
4
+ * `message:photo` is the one attachment type whose gateway handling is not a
5
+ * pure metadata render: it downloads the largest photo size from the Telegram
6
+ * CDN (bounded 15s fetch), writes it into the per-agent inbox with 0600 perms,
7
+ * and hands the on-disk path to the coalescing inbound pipeline as the image
8
+ * download. Split out of the cluster-C metadata handlers precisely because it
9
+ * carries side-effecting IO + bot-token handling.
10
+ *
11
+ * The registration line stays in gateway.ts (order is a load-bearing invariant
12
+ * pinned by gateway-handler-registration-wiring.test.ts) and delegates here.
13
+ * The bot token is read lazily via `getToken()` — it is materialized after
14
+ * this deps object is constructed — and never exposed in logs (redacted on the
15
+ * error path). Path-safety helpers are imported directly.
16
+ */
17
+
18
+ import { mkdirSync, writeFileSync } from 'node:fs'
19
+ import type { Context, Filter } from 'grammy'
20
+ import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
21
+
22
+ export interface PhotoHandlerDeps {
23
+ handleInboundCoalesced: (
24
+ ctx: Context,
25
+ text: string,
26
+ downloadImage: (() => Promise<string | undefined>) | undefined,
27
+ ) => Promise<void>
28
+ /** Lazily read the materialized bot token (set after deps construction). */
29
+ getToken: () => string
30
+ /** Per-agent inbox directory (0700). */
31
+ inboxDir: string
32
+ /** stderr log sink. */
33
+ log: (line: string) => void
34
+ }
35
+
36
+ export async function handlePhotoMessage(
37
+ ctx: Filter<Context, 'message:photo'>,
38
+ deps: PhotoHandlerDeps,
39
+ ): Promise<void> {
40
+ const caption = ctx.message.caption ?? '(photo)'
41
+ await deps.handleInboundCoalesced(ctx, caption, async () => {
42
+ const photos = ctx.message.photo
43
+ const best = photos[photos.length - 1]
44
+ try {
45
+ const file = await ctx.api.getFile(best.file_id)
46
+ if (!file.file_path) return undefined
47
+ // Build download URL — token is embedded in the URL but never exposed
48
+ // in error messages or logs (caught and sanitized below).
49
+ //
50
+ // Bounded fetch: a stalled Telegram CDN connection without a
51
+ // timeout would hang the entire inbound handler, blocking the
52
+ // user's photo from ever being acked or seen by the agent.
53
+ // 15s is generous for normal photos (typical 100ms-2s) and
54
+ // tight enough to surface a real outage.
55
+ const token = deps.getToken()
56
+ const downloadUrl = `https://api.telegram.org/file/bot${token}/${file.file_path}`
57
+ const res = await fetch(downloadUrl, { signal: AbortSignal.timeout(15_000) })
58
+ if (!res.ok) {
59
+ deps.log(`telegram gateway: photo download failed: HTTP ${res.status}\n`)
60
+ return undefined
61
+ }
62
+ const buf = Buffer.from(await res.arrayBuffer())
63
+ const dlPath = buildAttachmentPath({
64
+ inboxDir: deps.inboxDir,
65
+ telegramFilePath: file.file_path,
66
+ fileUniqueId: best.file_unique_id,
67
+ now: Date.now(),
68
+ })
69
+ mkdirSync(deps.inboxDir, { recursive: true, mode: 0o700 })
70
+ assertInsideInbox(deps.inboxDir, dlPath)
71
+ writeFileSync(dlPath, buf, { mode: 0o600 })
72
+ return dlPath
73
+ } catch (err) {
74
+ // Sanitize error to avoid leaking bot token in logs
75
+ const msg = err instanceof Error ? err.message : 'unknown error'
76
+ deps.log(`telegram gateway: photo download failed: ${msg.replace(deps.getToken(), '<REDACTED>')}\n`)
77
+ return undefined
78
+ }
79
+ })
80
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * `message:pinned_message` service-message cleanup (switchroom#2996 P6 cluster F).
3
+ *
4
+ * Suppresses the "pinned a message" service message Telegram inserts when OUR
5
+ * silent status-pin fires. The pin call passes `disable_notification: true`,
6
+ * which kills the PUSH notification but NOT the in-chat service message — so we
7
+ * delete that service message as it lands, but ONLY for pins we own (tracked in
8
+ * `statusPinState`). Manual/operator pins are never silent and are never
9
+ * touched. Only silent status pins reach here as an OUR-pin match, so the
10
+ * ownership check is the guard.
11
+ *
12
+ * The registration line stays in gateway.ts (order is a load-bearing invariant
13
+ * pinned by gateway-handler-registration-wiring.test.ts) and delegates here.
14
+ *
15
+ * DI note (check-bot-api-wrapping): the actual `lockedBot.api.deleteMessage`
16
+ * call is NOT made here — it is injected as `deleteServiceMessage`, bound in
17
+ * gateway.ts inside a `robustApiCall(...)` wrapper. This keeps the raw
18
+ * `lockedBot.api.*` callsite in gateway.ts (already inside the retry policy)
19
+ * and leaves this module with no raw Bot API site to allowlist.
20
+ */
21
+
22
+ import type { Context, Filter } from 'grammy'
23
+ import { pinnedMessageIsOurs, type TrackedStatusPin } from './status-pin-store.js'
24
+ import type { PinState } from '../status-pin.js'
25
+
26
+ export interface PinnedMessageHandlerDeps {
27
+ /** Live pinKey→PinState registry of pins we own. */
28
+ statusPinState: ReadonlyMap<string, PinState>
29
+ /** Companion pinKey→chatId registry (chat-scoped ownership). */
30
+ statusPinChatIds: ReadonlyMap<string, string>
31
+ /**
32
+ * Delete the pin service message. Bound in gateway.ts through
33
+ * `robustApiCall(() => lockedBot.api.deleteMessage(...))` so the raw Bot API
34
+ * call stays inside the retry policy. Rethrows on failure (the caller logs).
35
+ */
36
+ deleteServiceMessage: (chatId: string, messageId: number) => Promise<void>
37
+ /** stderr log sink. */
38
+ log: (line: string) => void
39
+ }
40
+
41
+ export async function handlePinnedMessage(
42
+ ctx: Filter<Context, 'message:pinned_message'>,
43
+ deps: PinnedMessageHandlerDeps,
44
+ ): Promise<void> {
45
+ const pinnedId = ctx.msg.pinned_message?.message_id
46
+ if (pinnedId == null) return
47
+ const chatId = String(ctx.chat.id)
48
+ const serviceMsgId = ctx.msg.message_id
49
+
50
+ // Chat-scoped ownership (see pinnedMessageIsOurs): the match requires BOTH
51
+ // the messageId AND that the tracked entry lives in THIS chat, so a pin id
52
+ // colliding across chats can't delete a foreign (e.g. operator-manual) pin
53
+ // notice. statusPinChatIds is the companion pinKey→chatId map written on
54
+ // every desired-pinned reconcile.
55
+ const trackedPins = (): TrackedStatusPin[] => {
56
+ const out: TrackedStatusPin[] = []
57
+ for (const [pinKey, state] of deps.statusPinState) {
58
+ const c = deps.statusPinChatIds.get(pinKey)
59
+ if (c != null) out.push({ chatId: c, messageId: state.messageId })
60
+ }
61
+ return out
62
+ }
63
+ const isOurs = () => pinnedMessageIsOurs(trackedPins(), chatId, pinnedId)
64
+
65
+ if (!isOurs()) {
66
+ // Tolerate the reconcile-store race: wait briefly, then re-check once.
67
+ await new Promise(resolve => setTimeout(resolve, 250))
68
+ if (!isOurs()) return
69
+ }
70
+
71
+ try {
72
+ await deps.deleteServiceMessage(chatId, serviceMsgId)
73
+ } catch (err) {
74
+ // Best-effort: a failure to delete the service message is cosmetic only —
75
+ // the "pinned a message" line just stays. The most likely cause in a
76
+ // supergroup/forum is the bot lacking can_delete_messages admin right, so
77
+ // surface a concise one-liner (robustApiCall rethrows this case without
78
+ // logging a reason) rather than swallowing silently — an operator sees WHY.
79
+ const msg = err instanceof Error ? err.message : String(err)
80
+ deps.log(
81
+ `telegram gateway: status-pin: could not delete pin service message ` +
82
+ `(chat=${chatId} msg=${serviceMsgId}) — likely missing can_delete_messages ` +
83
+ `admin right in this chat: ${msg}\n`,
84
+ )
85
+ }
86
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ renderSecretRequestCard,
4
+ buildSecretRequestKeyboard,
5
+ } from './secret-request-card.js'
6
+
7
+ describe('secret-request-card (#2996 P5 extraction — verbatim from gateway.ts)', () => {
8
+ describe('renderSecretRequestCard', () => {
9
+ it('renders the title, key, reason, and the provide-securely instruction', () => {
10
+ const out = renderSecretRequestCard({
11
+ agent: 'worker',
12
+ key: 'openai/api_key',
13
+ reason: 'need it to call the API',
14
+ })
15
+ expect(out).toContain('🔒 **worker** needs a secret:')
16
+ expect(out).toContain('`openai/api_key`')
17
+ expect(out).toContain('_need it to call the API_')
18
+ expect(out).toContain('Tap **Provide securely**')
19
+ })
20
+
21
+ it('omits the reason line when none is given', () => {
22
+ const out = renderSecretRequestCard({ agent: 'worker', key: 'openai/api_key' })
23
+ // The reason line is the only `_…_` emphasis block; absent when no reason.
24
+ expect(out).not.toMatch(/\n_.*_$/m)
25
+ })
26
+
27
+ it('does NOT harden card breaks (prose body, preserved from the inline version)', () => {
28
+ // The inline renderer joined lines plainly (no hardenCardBreaks); this
29
+ // pins that difference from the vault-save card so the move stays verbatim.
30
+ const out = renderSecretRequestCard({ agent: 'worker', key: 'k/v' })
31
+ expect(out.split('\n')[0]).toBe('🔒 **worker** needs a secret:')
32
+ })
33
+ })
34
+
35
+ describe('buildSecretRequestKeyboard', () => {
36
+ it('carries the vsp:* callback_data contract', () => {
37
+ const kb = buildSecretRequestKeyboard('abcd1234')
38
+ const flat = kb.inline_keyboard.flat()
39
+ expect(flat.map((b) => b.callback_data)).toEqual([
40
+ 'vsp:provide:abcd1234',
41
+ 'vsp:decline:abcd1234',
42
+ ])
43
+ expect(flat.map((b) => b.text)).toEqual(['🔐 Provide securely', '🚫 Decline'])
44
+ })
45
+ })
46
+ })
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Pure renderer + inline-keyboard builder for the agent-initiated
3
+ * `request_secret` card (#2045 — the agent does NOT have a value and asks the
4
+ * operator to PROVIDE one securely). Callback prefix `vsp:`
5
+ * (vault-secret-provide), handled by handleSecretRequestCallback in
6
+ * callback-query-handlers.ts.
7
+ *
8
+ * Extracted verbatim from gateway.ts (#2996 P5); the render body is unchanged.
9
+ * Unlike the vault-save card this renderer does NOT call hardenCardBreaks — the
10
+ * body is a short prose block, not labelled field lines — so the move preserves
11
+ * that difference exactly.
12
+ */
13
+
14
+ import { escapeHtmlForTg } from '../shared/bot-runtime.js'
15
+
16
+ /** Minimal shape the card needs — a subset of PendingSecretRequest. */
17
+ export interface SecretRequestCardInput {
18
+ agent: string
19
+ key: string
20
+ reason?: string
21
+ }
22
+
23
+ export function renderSecretRequestCard(req: SecretRequestCardInput): string {
24
+ const lines: string[] = [
25
+ `🔒 **${escapeHtmlForTg(req.agent)}** needs a secret:`,
26
+ `\`${req.key}\``,
27
+ ]
28
+ if (req.reason) lines.push(`_${escapeHtmlForTg(req.reason)}_`)
29
+ lines.push(
30
+ '',
31
+ 'Tap **Provide securely**, then send the value as your next message. I’ll delete it instantly and store it in the vault — it is never shown in chat or to the agent.',
32
+ )
33
+ return lines.join('\n')
34
+ }
35
+
36
+ export function buildSecretRequestKeyboard(stageId: string): { inline_keyboard: Array<Array<{ text: string; callback_data: string }>> } {
37
+ return {
38
+ inline_keyboard: [
39
+ [
40
+ { text: '🔐 Provide securely', callback_data: `vsp:provide:${stageId}` },
41
+ { text: '🚫 Decline', callback_data: `vsp:decline:${stageId}` },
42
+ ],
43
+ ],
44
+ }
45
+ }