switchroom 0.14.45 → 0.14.47

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.
@@ -2240,6 +2240,47 @@ function resumeReactionAfterVerdict(): void {
2240
2240
  ?.setThinking()
2241
2241
  }
2242
2242
 
2243
+ /**
2244
+ * The recipient set for a permission card (the initial Approve/Deny card
2245
+ * AND the post-verdict resume message — they MUST route identically, so
2246
+ * both go through this one helper).
2247
+ *
2248
+ * Turn-initiated (the normal case — a permission gate fires mid-tool-use
2249
+ * with an active turn): send to the ORIGINATING chat+thread. For a
2250
+ * supergroup-owned agent working in a forum topic that is the supergroup +
2251
+ * the topic, so the card lands IN the topic the operator asked from (e.g.
2252
+ * marko's "CRM (Brevo)" topic) — not the operator's DM. For a DM agent the
2253
+ * originating chat IS the operator's DM (thread-less), unchanged.
2254
+ *
2255
+ * No active turn (cron / background / a swept turn at TTL): fall back to the
2256
+ * configured operator DMs (`allowFrom`), thread-stripped via
2257
+ * `topicForRecipient` so a DM never gets a `message_thread_id` (the 400
2258
+ * "message thread not found" → auto-deny wedge, #2096).
2259
+ *
2260
+ * Before this helper the INITIAL card emitter iterated `allowFrom`
2261
+ * unconditionally, so a supergroup card could only ever reach operator DMs —
2262
+ * the topic chat id is never in `allowFrom`. The resume message already
2263
+ * routed correctly; the card now matches it (marko, 2026-06-03).
2264
+ */
2265
+ function resolvePermissionCardTargets(): Array<{ chatId: string; threadId: number | undefined }> {
2266
+ const turn = currentTurn
2267
+ if (turn != null) {
2268
+ return [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
2269
+ }
2270
+ const sg = resolveAgentSupergroupChatId()
2271
+ const topic = resolveAgentOutboundTopic({
2272
+ kind: 'permission',
2273
+ turnInitiated: false,
2274
+ originThreadId: undefined,
2275
+ })
2276
+ // allowFrom is normally operator DMs — attach the topic only to a
2277
+ // recipient that owns it (the supergroup), never a DM (marko wedge).
2278
+ return loadAccess().allowFrom.map(chatId => ({
2279
+ chatId,
2280
+ threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg }),
2281
+ }))
2282
+ }
2283
+
2243
2284
  /**
2244
2285
  * Post the agent-voiced "got your verdict — continuing" message the
2245
2286
  * instant the operator answers a permission card. Travels right beside
@@ -2269,24 +2310,7 @@ function postPermissionResumeMessage(opts: {
2269
2310
  action: opts.action,
2270
2311
  timeoutMinutes: opts.timeoutMinutes,
2271
2312
  })
2272
- const turn = currentTurn
2273
- const targets: Array<{ chatId: string; threadId: number | undefined }> =
2274
- turn != null
2275
- ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
2276
- : (() => {
2277
- const sg = resolveAgentSupergroupChatId()
2278
- const topic = resolveAgentOutboundTopic({
2279
- kind: 'permission',
2280
- turnInitiated: false,
2281
- originThreadId: undefined,
2282
- })
2283
- // allowFrom is normally operator DMs — attach the topic only to a
2284
- // recipient that owns it (the supergroup), never a DM (marko wedge).
2285
- return loadAccess().allowFrom.map(chatId => ({
2286
- chatId,
2287
- threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg }),
2288
- }))
2289
- })()
2313
+ const targets = resolvePermissionCardTargets()
2290
2314
  for (const { chatId, threadId } of targets) {
2291
2315
  // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
2292
2316
  void swallowingApiCall(
@@ -2913,6 +2937,10 @@ interface PendingVaultRequestSave {
2913
2937
  chat_id: string
2914
2938
  /** Card message id (filled in after we send the card). */
2915
2939
  card_message_id?: number
2940
+ /** Supergroup forum topic the agent was working in when it requested the
2941
+ * save — carried into the save-outcome inbound so the resumed reply lands
2942
+ * back in that topic, not General. */
2943
+ threadId?: number
2916
2944
  /** Currently-suggested slug; may be renamed by the user. */
2917
2945
  key: string
2918
2946
  /** Storage shape — 'string' (default) or 'binary'. */
@@ -2954,6 +2982,10 @@ interface PendingVaultRequestAccess {
2954
2982
  chat_id: string
2955
2983
  /** Card message id (filled in after we send the card). */
2956
2984
  card_message_id?: number
2985
+ /** Supergroup forum topic the agent was working in when it requested (the
2986
+ * card's originating thread). Carried into the grant-outcome inbound so the
2987
+ * resumed reply lands back in that topic, not General. */
2988
+ threadId?: number
2957
2989
  /** Vault key the agent wants to read. */
2958
2990
  key: string
2959
2991
  /** 'read' (default) or 'write'. */
@@ -4657,7 +4689,6 @@ const ipcServer: IpcServer = createIpcServer({
4657
4689
  onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
4658
4690
  const { requestId, toolName, description, inputPreview } = msg
4659
4691
  pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
4660
- const access = loadAccess()
4661
4692
  // Natural-language card body — a plain sentence ("Gymbro wants to
4662
4693
  // edit: supplement-log.md" + a why-line), never a raw tool id.
4663
4694
  // The operator sees what is being requested and why at a glance.
@@ -4677,42 +4708,34 @@ const ipcServer: IpcServer = createIpcServer({
4677
4708
  // two-button row only.
4678
4709
  const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
4679
4710
  const keyboard = buildPermissionActionRow(requestId, showAlways)
4680
- // PR4b emitter sweep supergroup-mode permission card routing.
4681
- // Per CPO #3 the design is "turn-initiated requests follow the
4682
- // conversation topic; background requests go to admin alias."
4683
- // Permission requests come from the bridge mid-tool-use, so they
4684
- // are always turn-initiated in practice the currently active
4685
- // turn's sessionThreadId is the originating topic. Fall back to
4686
- // admin alias when no active turn (cron / background path).
4687
- // Fleet-shared / DM agents see `undefined` no
4688
- // `message_thread_id` is added → behavior unchanged.
4689
- // currentTurn is the singleton "claude is currently on this turn"
4690
- // pointer — per Framing 1 / PR3b scope-discovery, claude
4691
- // serializes so there's exactly one (or zero) active turn at any
4692
- // moment. When set, the permission request is in-flight for that
4693
- // turn and follows the originating topic.
4711
+ // Route the card to the SAME place the post-verdict resume message
4712
+ // lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
4713
+ // there's an active turn so a supergroup agent's card appears IN the
4714
+ // topic the operator asked from (marko's "CRM (Brevo)"), not the
4715
+ // operator DM else the configured operator DMs, thread-stripped. The
4716
+ // old code iterated `allowFrom` unconditionally, so a supergroup card
4717
+ // could only ever reach operator DMs (the topic chat id is never in
4718
+ // `allowFrom`) (marko, 2026-06-03).
4694
4719
  const activeTurn = currentTurn
4695
- const permTopic = resolveAgentOutboundTopic({
4696
- kind: 'permission',
4697
- turnInitiated: activeTurn != null,
4698
- originThreadId: activeTurn?.sessionThreadId,
4699
- })
4700
- const permSupergroup = resolveAgentSupergroupChatId()
4701
- for (const chat_id of access.allowFrom) {
4702
- // parse_mode=HTML pairs with formatPermissionCardBody (#1790)
4703
- // so the <b>/<i> tags render as formatting.
4704
- // The resolved topic is valid only in the agent's supergroup — attach
4705
- // it ONLY when this recipient IS that supergroup. allowFrom DMs get the
4706
- // card thread-less; attaching a topic to a DM yields 400 "message thread
4707
- // not found" → card never arrives → auto-deny → wedge (marko 2026-06-02).
4708
- const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup })
4709
- // allow-raw-bot-api: permission-request keyboard fan-out; topic-aware opts
4710
- void bot.api.sendMessage(chat_id, text, {
4711
- parse_mode: 'HTML',
4712
- reply_markup: keyboard,
4713
- ...(permThread != null ? { message_thread_id: permThread } : {}),
4714
- }).catch(e => {
4715
- process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
4720
+ const targets = resolvePermissionCardTargets()
4721
+ for (const { chatId, threadId } of targets) {
4722
+ // parse_mode=HTML pairs with formatPermissionCardBody (#1790) so the
4723
+ // <b>/<i> tags render. retryWithThreadFallback: if the topic was
4724
+ // deleted/recreated (stale thread id → 400 "message thread not
4725
+ // found"), re-send thread-less into the main chat so the card still
4726
+ // ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
4727
+ // allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware send
4728
+ void retryWithThreadFallback<{ message_id: number }>(
4729
+ robustApiCall,
4730
+ (tid) =>
4731
+ bot.api.sendMessage(chatId, text, {
4732
+ parse_mode: 'HTML',
4733
+ reply_markup: keyboard,
4734
+ ...(tid != null ? { message_thread_id: tid } : {}),
4735
+ }),
4736
+ { threadId, chat_id: chatId, verb: 'permission_request' },
4737
+ ).catch(e => {
4738
+ process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
4716
4739
  })
4717
4740
  }
4718
4741
  // Park the turn's status reaction on 🙏 (awaiting your tap) and
@@ -7080,6 +7103,8 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
7080
7103
  // crashing the tool call.
7081
7104
  const text = renderVaultRequestSaveCard(pending, agentSlug)
7082
7105
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
7106
+ // Remember the agent's working topic so the save-outcome inbound resumes in it.
7107
+ if (threadId != null) pending.threadId = threadId
7083
7108
  const sent = await retryWithThreadFallback<{ message_id: number }>(
7084
7109
  robustApiCall,
7085
7110
  (tid) =>
@@ -7120,12 +7145,16 @@ interface PendingSecretRequest {
7120
7145
  reason?: string
7121
7146
  staged_at: number
7122
7147
  card_message_id?: number
7148
+ /** Supergroup forum topic the agent was working in — carried into the
7149
+ * provide/decline/fail outcome inbounds so the resumed reply lands back
7150
+ * in that topic, not General. */
7151
+ threadId?: number
7123
7152
  }
7124
7153
  // stageId -> request (lives until tapped or TTL).
7125
7154
  const pendingSecretRequests = new Map<string, PendingSecretRequest>()
7126
7155
  // chat_id -> the armed capture: the operator's NEXT message in this chat is
7127
7156
  // the value for `key`. Set when [Provide securely] is tapped.
7128
- interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number }
7157
+ interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number; threadId?: number }
7129
7158
  const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
7130
7159
  const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
7131
7160
  const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
@@ -7194,6 +7223,8 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
7194
7223
 
7195
7224
  const text = renderSecretRequestCard(pending)
7196
7225
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
7226
+ // Remember the agent's working topic so the provide/decline/fail inbound resumes in it.
7227
+ if (threadId != null) pending.threadId = threadId
7197
7228
  const sent = await retryWithThreadFallback<{ message_id: number }>(
7198
7229
  robustApiCall,
7199
7230
  (tid) =>
@@ -7269,12 +7300,19 @@ async function captureProvidedSecret(
7269
7300
  const failMsg: InboundMessage = {
7270
7301
  type: 'inbound',
7271
7302
  chatId: chat_id,
7303
+ ...(armed.threadId != null ? { threadId: armed.threadId } : {}),
7272
7304
  messageId: fts,
7273
7305
  user: 'vault-broker',
7274
7306
  userId: 0,
7275
7307
  ts: fts,
7276
7308
  text: `⚠️ The secret you requested for \`vault:${armed.key}\` could NOT be saved (vault write failed). Do not assume it is available; tell the operator or try request_secret again.`,
7277
- meta: { source: 'secret_provide_failed', agent: armed.agent, key: armed.key, stage_id: armed.stageId },
7309
+ meta: {
7310
+ source: 'secret_provide_failed',
7311
+ agent: armed.agent,
7312
+ ...(armed.threadId != null ? { message_thread_id: String(armed.threadId) } : {}),
7313
+ key: armed.key,
7314
+ stage_id: armed.stageId,
7315
+ },
7278
7316
  }
7279
7317
  const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg)
7280
7318
  if (fdelivered) markClaudeBusyForInbound(failMsg)
@@ -7294,6 +7332,7 @@ async function captureProvidedSecret(
7294
7332
  const synthetic: InboundMessage = {
7295
7333
  type: 'inbound',
7296
7334
  chatId: chat_id,
7335
+ ...(armed.threadId != null ? { threadId: armed.threadId } : {}),
7297
7336
  messageId: ts,
7298
7337
  user: 'vault-broker',
7299
7338
  userId: 0,
@@ -7305,6 +7344,7 @@ async function captureProvidedSecret(
7305
7344
  meta: {
7306
7345
  source: 'secret_provided',
7307
7346
  agent: armed.agent,
7347
+ ...(armed.threadId != null ? { message_thread_id: String(armed.threadId) } : {}),
7308
7348
  key: armed.key,
7309
7349
  stage_id: armed.stageId,
7310
7350
  },
@@ -7345,6 +7385,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
7345
7385
  agent: pending.agent,
7346
7386
  stageId,
7347
7387
  armed_at: Date.now(),
7388
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
7348
7389
  })
7349
7390
  await ctx.answerCallbackQuery({ text: 'Send the value now — it auto-deletes.' }).catch(() => {})
7350
7391
  if (pending.card_message_id != null) {
@@ -7377,12 +7418,19 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
7377
7418
  const synthetic: InboundMessage = {
7378
7419
  type: 'inbound',
7379
7420
  chatId: pending.chat_id,
7421
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
7380
7422
  messageId: ts,
7381
7423
  user: 'vault-broker',
7382
7424
  userId: 0,
7383
7425
  ts,
7384
7426
  text: `🚫 Operator declined your request for \`vault:${pending.key}\`. Proceed without it or ask how they'd like to handle the task.`,
7385
- meta: { source: 'secret_declined', agent: pending.agent, key: pending.key, stage_id: stageId },
7427
+ meta: {
7428
+ source: 'secret_declined',
7429
+ agent: pending.agent,
7430
+ ...(pending.threadId != null ? { message_thread_id: String(pending.threadId) } : {}),
7431
+ key: pending.key,
7432
+ stage_id: stageId,
7433
+ },
7386
7434
  }
7387
7435
  const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
7388
7436
  if (delivered) markClaudeBusyForInbound(synthetic)
@@ -7530,6 +7578,8 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
7530
7578
 
7531
7579
  const text = renderVaultRequestAccessCard(pending)
7532
7580
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
7581
+ // Remember the agent's working topic so the grant-outcome inbound resumes in it.
7582
+ if (threadId != null) pending.threadId = threadId
7533
7583
  // #1075: deleted-topic safe — fall back to main chat.
7534
7584
  const sent = await retryWithThreadFallback<{ message_id: number }>(
7535
7585
  robustApiCall,
@@ -14023,6 +14073,7 @@ async function performVaultAccessApproval(
14023
14073
  scope: pending.scope,
14024
14074
  chat_id: pending.chat_id,
14025
14075
  ttl_seconds: pending.ttl_seconds,
14076
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14026
14077
  },
14027
14078
  grantId: id,
14028
14079
  stageId,
@@ -14104,6 +14155,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
14104
14155
  scope: pending.scope,
14105
14156
  chat_id: pending.chat_id,
14106
14157
  ttl_seconds: pending.ttl_seconds,
14158
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14107
14159
  },
14108
14160
  stageId,
14109
14161
  operatorId: senderId,
@@ -14278,7 +14330,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
14278
14330
  // tool returned "waiting for operator", the turn ended, and a
14279
14331
  // Discard left the agent silently idle forever.
14280
14332
  const discardInbound = buildVaultSaveDiscardedInbound({
14281
- ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
14333
+ ctx: {
14334
+ agent: pending.agent,
14335
+ key: pending.key,
14336
+ chat_id: pending.chat_id,
14337
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14338
+ },
14282
14339
  stageId,
14283
14340
  operatorId: senderId,
14284
14341
  })
@@ -14401,7 +14458,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
14401
14458
  const failReason =
14402
14459
  (write.output || 'vault write error').split('\n')[0]!.slice(0, 200)
14403
14460
  const failInbound = buildVaultSaveFailedInbound({
14404
- ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
14461
+ ctx: {
14462
+ agent: pending.agent,
14463
+ key: pending.key,
14464
+ chat_id: pending.chat_id,
14465
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14466
+ },
14405
14467
  stageId,
14406
14468
  operatorId: senderId,
14407
14469
  reason: failReason,
@@ -14432,7 +14494,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
14432
14494
  // task that was blocked on this credential (symmetric with the
14433
14495
  // vra: approve path; buffered if the bridge is mid-reconnect).
14434
14496
  const okInbound = buildVaultSaveCompletedInbound({
14435
- ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
14497
+ ctx: {
14498
+ agent: pending.agent,
14499
+ key: pending.key,
14500
+ chat_id: pending.chat_id,
14501
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14502
+ },
14436
14503
  stageId,
14437
14504
  operatorId: senderId,
14438
14505
  })
@@ -34,6 +34,10 @@ export interface VaultGrantInboundContext {
34
34
  chat_id: string
35
35
  /** Seconds. For approved grants; ignored for deny. */
36
36
  ttl_seconds: number
37
+ /** Supergroup forum topic (message_thread_id) the agent was working in
38
+ * when it requested the credential — so the resumed turn's reply lands
39
+ * back in that topic, not General. Undefined for DM / non-topic requests. */
40
+ threadId?: number
37
41
  }
38
42
 
39
43
  /**
@@ -62,6 +66,7 @@ export function buildVaultGrantApprovedInbound(opts: {
62
66
  return {
63
67
  type: 'inbound',
64
68
  chatId: opts.ctx.chat_id,
69
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
65
70
  messageId: ts, // synthetic — no Telegram message id exists
66
71
  user: 'vault-broker',
67
72
  userId: 0,
@@ -76,6 +81,7 @@ export function buildVaultGrantApprovedInbound(opts: {
76
81
  meta: {
77
82
  source: 'vault_grant_approved',
78
83
  agent: opts.ctx.agent,
84
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
79
85
  key: opts.ctx.key,
80
86
  scope: opts.ctx.scope,
81
87
  grant_id: opts.grantId,
@@ -103,6 +109,7 @@ export function buildVaultGrantDeniedInbound(opts: {
103
109
  return {
104
110
  type: 'inbound',
105
111
  chatId: opts.ctx.chat_id,
112
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
106
113
  messageId: ts,
107
114
  user: 'vault-broker',
108
115
  userId: 0,
@@ -116,6 +123,7 @@ export function buildVaultGrantDeniedInbound(opts: {
116
123
  meta: {
117
124
  source: 'vault_grant_denied',
118
125
  agent: opts.ctx.agent,
126
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
119
127
  key: opts.ctx.key,
120
128
  scope: opts.ctx.scope,
121
129
  stage_id: opts.stageId,
@@ -133,6 +141,9 @@ export interface VaultSaveInboundContext {
133
141
  /** Telegram chat the save card lived in — keeps the synthesized
134
142
  * resume-turn associated with the originating conversation. */
135
143
  chat_id: string
144
+ /** Supergroup forum topic the agent was working in when it requested the
145
+ * save — so the resumed reply lands in that topic, not General. */
146
+ threadId?: number
136
147
  }
137
148
 
138
149
  /**
@@ -154,6 +165,7 @@ export function buildVaultSaveCompletedInbound(opts: {
154
165
  return {
155
166
  type: 'inbound',
156
167
  chatId: opts.ctx.chat_id,
168
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
157
169
  messageId: ts,
158
170
  user: 'vault-broker',
159
171
  userId: 0,
@@ -165,6 +177,7 @@ export function buildVaultSaveCompletedInbound(opts: {
165
177
  meta: {
166
178
  source: 'vault_save_completed',
167
179
  agent: opts.ctx.agent,
180
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
168
181
  key: opts.ctx.key,
169
182
  stage_id: opts.stageId,
170
183
  operator_id: opts.operatorId,
@@ -187,6 +200,7 @@ export function buildVaultSaveFailedInbound(opts: {
187
200
  return {
188
201
  type: 'inbound',
189
202
  chatId: opts.ctx.chat_id,
203
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
190
204
  messageId: ts,
191
205
  user: 'vault-broker',
192
206
  userId: 0,
@@ -200,6 +214,7 @@ export function buildVaultSaveFailedInbound(opts: {
200
214
  meta: {
201
215
  source: 'vault_save_failed',
202
216
  agent: opts.ctx.agent,
217
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
203
218
  key: opts.ctx.key,
204
219
  stage_id: opts.stageId,
205
220
  operator_id: opts.operatorId,
@@ -221,6 +236,7 @@ export function buildVaultSaveDiscardedInbound(opts: {
221
236
  return {
222
237
  type: 'inbound',
223
238
  chatId: opts.ctx.chat_id,
239
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
224
240
  messageId: ts,
225
241
  user: 'vault-broker',
226
242
  userId: 0,
@@ -234,6 +250,7 @@ export function buildVaultSaveDiscardedInbound(opts: {
234
250
  meta: {
235
251
  source: 'vault_save_discarded',
236
252
  agent: opts.ctx.agent,
253
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
237
254
  key: opts.ctx.key,
238
255
  stage_id: opts.stageId,
239
256
  operator_id: opts.operatorId,
@@ -19,10 +19,21 @@
19
19
 
20
20
  import { basename } from "node:path";
21
21
  import { prettyMcpServer, type ScopeOption } from "./permission-rule.js";
22
+ import { redact } from "./secret-detect/redact.js";
22
23
 
23
24
  const COMMAND_TITLE_MAX = 48;
24
25
  const DESCRIPTION_LINE_MAX = 240;
25
26
 
27
+ /** HTTP methods the generic REST-wrapper MCP tools (brevo/meta/postiz/… via
28
+ * rest-server.mjs) expose as verbs — uppercased on the card so the operator
29
+ * reads "POST /smtp/email" as an API write, not "post". */
30
+ const HTTP_VERBS = new Set(["get", "post", "put", "patch", "delete", "head"]);
31
+ /** Keys that, on a REST-style MCP input, name the resource/endpoint. */
32
+ const RESOURCE_KEYS = ["path", "endpoint", "url", "resource", "route"];
33
+ const ARG_SUMMARY_MAX_KEYS = 4; // how many payload keys to surface on the card
34
+ const ARG_VALUE_MAX = 40; // per-value truncation in the arg-summary line
35
+ const ARG_SUMMARY_LINE_MAX = 180; // total cap for the arg-summary line
36
+
26
37
  /**
27
38
  * Human verb-phrases for switchroom-managed MCP tools. The raw
28
39
  * `mcp__<server>__<tool>` name is operator-hostile. Phrases are written
@@ -104,6 +115,14 @@ export function formatPermissionCardBody(opts: {
104
115
  : `why: <i>not provided</i>`,
105
116
  );
106
117
 
118
+ // Third line (REST-wrapper MCP writes only): a redaction-safe summary of
119
+ // the payload so the operator can see WHAT is being sent, not just the
120
+ // endpoint — e.g. "↳ to: lisa@…, subject: Priority access…".
121
+ const argSummary = mcpArgSummary(opts.toolName, opts.inputPreview);
122
+ if (argSummary) {
123
+ lines.push(`↳ <i>${escapeTgHtml(argSummary)}</i>`);
124
+ }
125
+
107
126
  return lines.join("\n");
108
127
  }
109
128
 
@@ -171,7 +190,6 @@ function naturalMcpAction(
171
190
  toolName: string,
172
191
  input: Record<string, unknown> | null,
173
192
  ): string {
174
- void input;
175
193
  const parts = toolName.split("__");
176
194
  const server = parts.length >= 2 ? parts[1]! : "";
177
195
  const curated = MCP_TOOL_DESCRIPTIONS[toolName];
@@ -183,6 +201,15 @@ function naturalMcpAction(
183
201
  }
184
202
  if (parts.length >= 3) {
185
203
  const verb = parts.slice(2).join("__").replace(/_/g, " ");
204
+ // External REST-wrapper tools (brevo/meta/postiz/…) take a `path`. Name
205
+ // the endpoint so "post (Brevo)" becomes "POST /smtp/email (Brevo)" —
206
+ // the operator can see WHICH resource is being written, not just that
207
+ // *something* is. Internal servers + tools without a resource key keep
208
+ // the plain verb phrasing.
209
+ if (!INTERNAL_MCP_SERVERS.has(server)) {
210
+ const resourcePhrase = restResourcePhrase(server, verb, input);
211
+ if (resourcePhrase) return resourcePhrase;
212
+ }
186
213
  return INTERNAL_MCP_SERVERS.has(server)
187
214
  ? verb
188
215
  : `${verb} (${prettyMcpServer(server)})`;
@@ -190,6 +217,78 @@ function naturalMcpAction(
190
217
  return `use ${toolName}`;
191
218
  }
192
219
 
220
+ /**
221
+ * For a REST-wrapper MCP call ({ path, body?, query? }), build the action
222
+ * phrase "<VERB> <path> (<Server>)" — e.g. "POST /smtp/email (Brevo)". The
223
+ * path is redaction-passed + length-capped before display. Returns null
224
+ * when the input carries no recognizable resource key (caller falls back to
225
+ * the plain verb phrasing).
226
+ */
227
+ function restResourcePhrase(
228
+ server: string,
229
+ verb: string,
230
+ input: Record<string, unknown> | null,
231
+ ): string | null {
232
+ if (!input) return null;
233
+ let path: string | null = null;
234
+ for (const key of RESOURCE_KEYS) {
235
+ path = readString(input, key);
236
+ if (path) break;
237
+ }
238
+ if (!path) return null;
239
+ const v = HTTP_VERBS.has(verb.toLowerCase()) ? verb.toUpperCase() : verb;
240
+ const shownPath = truncate(redact(path), COMMAND_TITLE_MAX);
241
+ return `${v} ${shownPath} (${prettyMcpServer(server)})`;
242
+ }
243
+
244
+ /**
245
+ * A compact, redaction-safe one-line summary of a REST-wrapper MCP call's
246
+ * payload ({ body } for writes, { query } for reads) — the third card line.
247
+ * Shows up to {@link ARG_SUMMARY_MAX_KEYS} payload keys with short, masked
248
+ * scalar values ("to: lisa@…, subject: Priority access…"); nested
249
+ * objects/arrays surface as the bare key name (no value dump — avoids
250
+ * leaking PII/secrets and oversized blobs). Every value passes through
251
+ * `redact()` so an API key in the payload is masked, never surfaced.
252
+ * Returns null when there's nothing meaningful to show.
253
+ */
254
+ function mcpArgSummary(
255
+ toolName: string,
256
+ inputPreview: string | undefined,
257
+ ): string | null {
258
+ if (!toolName.startsWith("mcp__")) return null;
259
+ // Internal servers (agent-config / hostd / hindsight / telegram) use flat
260
+ // input schemas, not the REST `body`/`query` convention — and we don't
261
+ // endpoint-enrich their title line either, so keep the summary line off
262
+ // them too (redact() still runs, so this is intent-match, not a leak fix).
263
+ const server = toolName.split("__")[1] ?? "";
264
+ if (INTERNAL_MCP_SERVERS.has(server)) return null;
265
+ const input = parseInput(inputPreview);
266
+ if (!input) return null;
267
+ const payload = input.body ?? input.query;
268
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
269
+ return null;
270
+ }
271
+ const parts: string[] = [];
272
+ for (const [key, value] of Object.entries(payload as Record<string, unknown>)) {
273
+ if (parts.length >= ARG_SUMMARY_MAX_KEYS) {
274
+ parts.push("…");
275
+ break;
276
+ }
277
+ if (value == null) continue;
278
+ if (typeof value === "object") {
279
+ parts.push(key); // nested object/array → key name only, never dumped
280
+ continue;
281
+ }
282
+ const shown = truncate(redact(String(value)), ARG_VALUE_MAX);
283
+ parts.push(`${key}: ${shown}`);
284
+ }
285
+ if (parts.length === 0) return null;
286
+ const joined = parts.join(", ");
287
+ return joined.length > ARG_SUMMARY_LINE_MAX
288
+ ? joined.slice(0, ARG_SUMMARY_LINE_MAX - 1) + "…"
289
+ : joined;
290
+ }
291
+
193
292
  /**
194
293
  * Confirmation phrase describing a grant that just landed, derived from
195
294
  * the *scope option the operator chose* — so an always-allow's breadth
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Structural pin for permission-card topic routing.
3
+ *
4
+ * The bug (marko, 2026-06-03): the INITIAL Approve/Deny card emitter
5
+ * (`onPermissionRequest`) iterated `access.allowFrom` unconditionally as its
6
+ * recipient set. For a supergroup-owned agent, `allowFrom` holds only the
7
+ * operator DM user-ids — the supergroup chat id is never in it — so a
8
+ * permission card raised from a forum topic could ONLY ever land in the
9
+ * operator's DM, never in the topic the operator asked from. The
10
+ * post-verdict resume message already routed correctly (to the turn's
11
+ * originating chat+thread); the card did not.
12
+ *
13
+ * The fix routes BOTH the card and the resume through one shared helper,
14
+ * `resolvePermissionCardTargets()`, so they can't drift: turn-initiated →
15
+ * the originating chat+topic; no active turn → operator DMs, thread-stripped
16
+ * via topicForRecipient (the DM-thread 400 / auto-deny guard, #2096).
17
+ *
18
+ * gateway.ts is not unit-importable (top-level side effects), so this is a
19
+ * source-text pin in the same style as permission-verdict-resume-guard.ts.
20
+ * The routing *logic* (topicForRecipient / resolveAgentOutboundTopic) is
21
+ * unit-tested in src/telegram/topic-router.test.ts; the end-to-end
22
+ * "card lands in the topic" is covered by the supergroup UAT. This guards
23
+ * the wiring: that the card uses the shared helper and never reverts to the
24
+ * raw allowFrom fan-out.
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest'
28
+ import { readFileSync } from 'node:fs'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { dirname, resolve } from 'node:path'
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url))
33
+ const GATEWAY_SRC = readFileSync(
34
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
35
+ 'utf8',
36
+ )
37
+
38
+ /** Slice the body of the `onPermissionRequest` IPC handler — from its header
39
+ * to the next handler method (`onHeartbeat`). */
40
+ function onPermissionRequestBody(): string {
41
+ const start = GATEWAY_SRC.indexOf('onPermissionRequest(')
42
+ expect(start).toBeGreaterThan(-1)
43
+ const rest = GATEWAY_SRC.slice(start)
44
+ const end = rest.indexOf('onHeartbeat(')
45
+ expect(end).toBeGreaterThan(-1)
46
+ return rest.slice(0, end)
47
+ }
48
+
49
+ describe('permission card routing', () => {
50
+ it('the shared target helper exists', () => {
51
+ expect(
52
+ /function\s+resolvePermissionCardTargets\s*\(/.test(GATEWAY_SRC),
53
+ ).toBe(true)
54
+ })
55
+
56
+ it('the initial card emitter routes via resolvePermissionCardTargets()', () => {
57
+ expect(onPermissionRequestBody()).toContain('resolvePermissionCardTargets()')
58
+ })
59
+
60
+ it('the initial card emitter no longer iterates access.allowFrom directly (the bug shape)', () => {
61
+ // The raw fan-out loop is what sent supergroup cards to operator DMs.
62
+ expect(onPermissionRequestBody()).not.toMatch(
63
+ /for\s*\(\s*const\s+chat_id\s+of\s+access\.allowFrom\s*\)/,
64
+ )
65
+ })
66
+
67
+ it('the card send is wrapped in retryWithThreadFallback (stale-topic → thread-less, not a silent drop)', () => {
68
+ expect(onPermissionRequestBody()).toContain('retryWithThreadFallback')
69
+ })
70
+
71
+ it('the resume message uses the SAME helper, so card + resume cannot drift', () => {
72
+ const start = GATEWAY_SRC.indexOf('function postPermissionResumeMessage(')
73
+ expect(start).toBeGreaterThan(-1)
74
+ const body = GATEWAY_SRC.slice(start, start + 1400)
75
+ expect(body).toContain('resolvePermissionCardTargets()')
76
+ })
77
+ })