switchroom 0.14.46 → 0.14.48

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.
@@ -49462,8 +49462,8 @@ var {
49462
49462
  } = import__.default;
49463
49463
 
49464
49464
  // src/build-info.ts
49465
- var VERSION = "0.14.46";
49466
- var COMMIT_SHA = "df3deacf";
49465
+ var VERSION = "0.14.48";
49466
+ var COMMIT_SHA = "a6517652";
49467
49467
 
49468
49468
  // src/cli/agent.ts
49469
49469
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.46",
3
+ "version": "0.14.48",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46927,7 +46927,7 @@ function escapeHtml7(s) {
46927
46927
 
46928
46928
  // gateway/pending-inbound-buffer.ts
46929
46929
  var DEFAULT_PENDING_INBOUND_CAP = 32;
46930
- function redeliverBufferedInbound(buffer, agent, send, spool) {
46930
+ function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
46931
46931
  const pending = buffer.drain(agent);
46932
46932
  let redelivered = 0;
46933
46933
  let rebuffered = 0;
@@ -46942,6 +46942,7 @@ function redeliverBufferedInbound(buffer, agent, send, spool) {
46942
46942
  for (const o of originals)
46943
46943
  spool?.ack(o);
46944
46944
  redelivered += originals.length;
46945
+ onDelivered?.(merged, originals);
46945
46946
  } else {
46946
46947
  for (const o of originals)
46947
46948
  buffer.push(agent, o);
@@ -47004,14 +47005,14 @@ function mergeRun(run2) {
47004
47005
  merged.attachment = mediaEntry.attachment;
47005
47006
  return merged;
47006
47007
  }
47007
- function idleDrainTick(buffer, agent, isBridgeAlive, send, spool) {
47008
+ function idleDrainTick(buffer, agent, isBridgeAlive, send, spool, onDelivered) {
47008
47009
  if (!agent)
47009
47010
  return null;
47010
47011
  if (buffer.depth(agent) === 0)
47011
47012
  return null;
47012
47013
  if (!isBridgeAlive())
47013
47014
  return null;
47014
- return redeliverBufferedInbound(buffer, agent, send, spool);
47015
+ return redeliverBufferedInbound(buffer, agent, send, spool, onDelivered);
47015
47016
  }
47016
47017
  function createPendingInboundBuffer(opts = {}) {
47017
47018
  const cap = opts.capPerAgent ?? DEFAULT_PENDING_INBOUND_CAP;
@@ -47618,7 +47619,7 @@ function formatEventDetail(event) {
47618
47619
  }
47619
47620
 
47620
47621
  // gateway/pending-inbound-buffer.ts
47621
- function redeliverBufferedInbound2(buffer, agent, send, spool) {
47622
+ function redeliverBufferedInbound2(buffer, agent, send, spool, onDelivered) {
47622
47623
  const pending = buffer.drain(agent);
47623
47624
  let redelivered = 0;
47624
47625
  let rebuffered = 0;
@@ -47633,6 +47634,7 @@ function redeliverBufferedInbound2(buffer, agent, send, spool) {
47633
47634
  for (const o of originals)
47634
47635
  spool?.ack(o);
47635
47636
  redelivered += originals.length;
47637
+ onDelivered?.(merged, originals);
47636
47638
  } else {
47637
47639
  for (const o of originals)
47638
47640
  buffer.push(agent, o);
@@ -47725,7 +47727,7 @@ function dispatchOne(effect, ctx) {
47725
47727
  }
47726
47728
  return ctx.ipcServer.sendToAgent(ctx.selfAgent, msg);
47727
47729
  };
47728
- const result = redeliverBufferedInbound2(ctx.pendingInboundBuffer, ctx.selfAgent, send, ctx.inboundSpool ?? undefined);
47730
+ const result = redeliverBufferedInbound2(ctx.pendingInboundBuffer, ctx.selfAgent, send, ctx.inboundSpool ?? undefined, ctx.onUserInboundDelivered ? (merged) => ctx.onUserInboundDelivered(merged) : undefined);
47729
47731
  if (result.drained > 0) {
47730
47732
  log(`telegram gateway: dispatch drainBuffer agent=${ctx.selfAgent} ` + `drained=${result.drained} redelivered=${result.redelivered} ` + `rebuffered=${result.rebuffered}
47731
47733
  `);
@@ -51137,8 +51139,14 @@ function prettyMcpServer(server) {
51137
51139
  }
51138
51140
 
51139
51141
  // permission-title.ts
51142
+ init_redact();
51140
51143
  var COMMAND_TITLE_MAX = 48;
51141
51144
  var DESCRIPTION_LINE_MAX = 240;
51145
+ var HTTP_VERBS = new Set(["get", "post", "put", "patch", "delete", "head"]);
51146
+ var RESOURCE_KEYS = ["path", "endpoint", "url", "resource", "route"];
51147
+ var ARG_SUMMARY_MAX_KEYS = 4;
51148
+ var ARG_VALUE_MAX = 40;
51149
+ var ARG_SUMMARY_LINE_MAX = 180;
51142
51150
  var MCP_TOOL_DESCRIPTIONS = {
51143
51151
  "mcp__agent-config__config_get": "Read its own merged config",
51144
51152
  "mcp__agent-config__cron_list": "List its own scheduled tasks",
@@ -51179,6 +51187,10 @@ function formatPermissionCardBody(opts) {
51179
51187
  const rawWhy = (opts.description ?? "").replace(/\s+/g, " ").trim();
51180
51188
  const truncatedWhy = rawWhy.length > DESCRIPTION_LINE_MAX ? rawWhy.slice(0, DESCRIPTION_LINE_MAX - 1) + "\u2026" : rawWhy;
51181
51189
  lines.push(truncatedWhy.length > 0 ? `why: <i>${escapeTgHtml(truncatedWhy)}</i>` : `why: <i>not provided</i>`);
51190
+ const argSummary = mcpArgSummary(opts.toolName, opts.inputPreview);
51191
+ if (argSummary) {
51192
+ lines.push(`\u21b3 <i>${escapeTgHtml(argSummary)}</i>`);
51193
+ }
51182
51194
  return lines.join(`
51183
51195
  `);
51184
51196
  }
@@ -51243,10 +51255,63 @@ function naturalMcpAction(toolName, input) {
51243
51255
  }
51244
51256
  if (parts.length >= 3) {
51245
51257
  const verb = parts.slice(2).join("__").replace(/_/g, " ");
51258
+ if (!INTERNAL_MCP_SERVERS.has(server)) {
51259
+ const resourcePhrase = restResourcePhrase(server, verb, input);
51260
+ if (resourcePhrase)
51261
+ return resourcePhrase;
51262
+ }
51246
51263
  return INTERNAL_MCP_SERVERS.has(server) ? verb : `${verb} (${prettyMcpServer(server)})`;
51247
51264
  }
51248
51265
  return `use ${toolName}`;
51249
51266
  }
51267
+ function restResourcePhrase(server, verb, input) {
51268
+ if (!input)
51269
+ return null;
51270
+ let path = null;
51271
+ for (const key of RESOURCE_KEYS) {
51272
+ path = readString(input, key);
51273
+ if (path)
51274
+ break;
51275
+ }
51276
+ if (!path)
51277
+ return null;
51278
+ const v = HTTP_VERBS.has(verb.toLowerCase()) ? verb.toUpperCase() : verb;
51279
+ const shownPath = truncate6(redact(path), COMMAND_TITLE_MAX);
51280
+ return `${v} ${shownPath} (${prettyMcpServer(server)})`;
51281
+ }
51282
+ function mcpArgSummary(toolName, inputPreview) {
51283
+ if (!toolName.startsWith("mcp__"))
51284
+ return null;
51285
+ const server = toolName.split("__")[1] ?? "";
51286
+ if (INTERNAL_MCP_SERVERS.has(server))
51287
+ return null;
51288
+ const input = parseInput(inputPreview);
51289
+ if (!input)
51290
+ return null;
51291
+ const payload = input.body ?? input.query;
51292
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
51293
+ return null;
51294
+ }
51295
+ const parts = [];
51296
+ for (const [key, value] of Object.entries(payload)) {
51297
+ if (parts.length >= ARG_SUMMARY_MAX_KEYS) {
51298
+ parts.push("\u2026");
51299
+ break;
51300
+ }
51301
+ if (value == null)
51302
+ continue;
51303
+ if (typeof value === "object") {
51304
+ parts.push(key);
51305
+ continue;
51306
+ }
51307
+ const shown = truncate6(redact(String(value)), ARG_VALUE_MAX);
51308
+ parts.push(`${key}: ${shown}`);
51309
+ }
51310
+ if (parts.length === 0)
51311
+ return null;
51312
+ const joined = parts.join(", ");
51313
+ return joined.length > ARG_SUMMARY_LINE_MAX ? joined.slice(0, ARG_SUMMARY_LINE_MAX - 1) + "\u2026" : joined;
51314
+ }
51250
51315
  function describeGrant(toolName, inputPreview, option) {
51251
51316
  const rule = option.rule;
51252
51317
  if (rule.endsWith("__*") && rule.startsWith("mcp__")) {
@@ -52034,10 +52099,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52034
52099
  }
52035
52100
 
52036
52101
  // ../src/build-info.ts
52037
- var VERSION = "0.14.46";
52038
- var COMMIT_SHA = "df3deacf";
52039
- var COMMIT_DATE = "2026-06-03T05:00:14Z";
52040
- var LATEST_PR = 2116;
52102
+ var VERSION = "0.14.48";
52103
+ var COMMIT_SHA = "a6517652";
52104
+ var COMMIT_DATE = "2026-06-03T07:57:29Z";
52105
+ var LATEST_PR = 2120;
52041
52106
  var COMMITS_AHEAD_OF_TAG = 0;
52042
52107
 
52043
52108
  // gateway/boot-version.ts
@@ -52602,14 +52667,20 @@ function buildResumeWatchdogReportInbound(ctx) {
52602
52667
  meta
52603
52668
  };
52604
52669
  }
52605
- function selectResumeBuilder(endedVia) {
52670
+ function selectResumeBuilder(endedVia, opts) {
52671
+ let kind;
52606
52672
  if (endedVia === "timeout")
52673
+ kind = "report";
52674
+ else if (endedVia === "restart" || endedVia === "sigterm" || endedVia === "unknown")
52675
+ kind = "resume";
52676
+ else if (endedVia == null)
52677
+ kind = "resume";
52678
+ else
52679
+ kind = null;
52680
+ if (kind === "resume" && opts?.ageMs != null && opts?.maxAgeMs != null && opts.ageMs > opts.maxAgeMs) {
52607
52681
  return "report";
52608
- if (endedVia === "restart" || endedVia === "sigterm" || endedVia === "unknown")
52609
- return "resume";
52610
- if (endedVia == null)
52611
- return "resume";
52612
- return null;
52682
+ }
52683
+ return kind;
52613
52684
  }
52614
52685
 
52615
52686
  // registry/subagents-schema.ts
@@ -53060,7 +53131,14 @@ try {
53060
53131
  const pending2 = findLatestTurnIfInterrupted(turnsDb);
53061
53132
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
53062
53133
  if (pending2 != null && selfAgent) {
53063
- const kind = selectResumeBuilder(pending2.ended_via);
53134
+ const RESUME_MAX_AGE_MS = (() => {
53135
+ const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS);
53136
+ return Number.isFinite(v) && v > 0 ? v : 10800000;
53137
+ })();
53138
+ const kind = selectResumeBuilder(pending2.ended_via, {
53139
+ ageMs: Math.max(0, Date.now() - pending2.started_at),
53140
+ maxAgeMs: RESUME_MAX_AGE_MS
53141
+ });
53064
53142
  if (kind === "resume") {
53065
53143
  bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending2 }) };
53066
53144
  } else if (kind === "report") {
@@ -53327,7 +53405,7 @@ function purgeReactionTracking(key, endingTurn) {
53327
53405
  if (d)
53328
53406
  markClaudeBusyForInbound(m);
53329
53407
  return d;
53330
- }, inboundSpool);
53408
+ }, inboundSpool, trackRedeliveredInbound);
53331
53409
  if (fr.redelivered > 0) {
53332
53410
  process.stderr.write(`telegram gateway: turn-complete flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
53333
53411
  `);
@@ -53357,7 +53435,7 @@ function releaseTurnBufferGate(key) {
53357
53435
  if (d)
53358
53436
  markClaudeBusyForInbound(m);
53359
53437
  return d;
53360
- }, inboundSpool);
53438
+ }, inboundSpool, trackRedeliveredInbound);
53361
53439
  if (fr.redelivered > 0) {
53362
53440
  process.stderr.write(`telegram gateway: reply-released-gate flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
53363
53441
  `);
@@ -53520,6 +53598,22 @@ function resumeReactionAfterVerdict() {
53520
53598
  return;
53521
53599
  activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))?.setThinking();
53522
53600
  }
53601
+ function resolvePermissionCardTargets() {
53602
+ const turn = currentTurn;
53603
+ if (turn != null) {
53604
+ return [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }];
53605
+ }
53606
+ const sg = resolveAgentSupergroupChatId();
53607
+ const topic = resolveAgentOutboundTopic({
53608
+ kind: "permission",
53609
+ turnInitiated: false,
53610
+ originThreadId: undefined
53611
+ });
53612
+ return loadAccess().allowFrom.map((chatId) => ({
53613
+ chatId,
53614
+ threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg })
53615
+ }));
53616
+ }
53523
53617
  function postPermissionResumeMessage(opts) {
53524
53618
  if (process.env.SWITCHROOM_RESUME_MSG === "0")
53525
53619
  return;
@@ -53529,19 +53623,7 @@ function postPermissionResumeMessage(opts) {
53529
53623
  action: opts.action,
53530
53624
  timeoutMinutes: opts.timeoutMinutes
53531
53625
  });
53532
- const turn = currentTurn;
53533
- const targets = turn != null ? [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }] : (() => {
53534
- const sg = resolveAgentSupergroupChatId();
53535
- const topic = resolveAgentOutboundTopic({
53536
- kind: "permission",
53537
- turnInitiated: false,
53538
- originThreadId: undefined
53539
- });
53540
- return loadAccess().allowFrom.map((chatId) => ({
53541
- chatId,
53542
- threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg })
53543
- }));
53544
- })();
53626
+ const targets = resolvePermissionCardTargets();
53545
53627
  for (const { chatId, threadId } of targets) {
53546
53628
  swallowingApiCall(() => bot.api.sendMessage(chatId, text, {
53547
53629
  parse_mode: "HTML",
@@ -54339,7 +54421,7 @@ startTimer({
54339
54421
  if (d)
54340
54422
  markClaudeBusyForInbound(m);
54341
54423
  return d;
54342
- }, inboundSpool);
54424
+ }, inboundSpool, trackRedeliveredInbound);
54343
54425
  process.stderr.write(`telegram gateway: silence-poke framework-fallback ended wedged turn chat=${fbChatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs} currentTurn_nulled=${turnMatchesFallback} drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ""}${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ""}
54344
54426
  `);
54345
54427
  }
@@ -54349,6 +54431,20 @@ var _deliveryMachineTick = setInterval(() => {
54349
54431
  shadowEmit({ kind: "tick", now: Date.now() });
54350
54432
  }, DELIVERY_MACHINE_TICK_MS);
54351
54433
  _deliveryMachineTick.unref?.();
54434
+ function trackRedeliveredInbound(merged) {
54435
+ if (!DELIVERY_CONFIRM_ENABLED)
54436
+ return;
54437
+ if (!shouldTrackDelivery({
54438
+ isSteering: false,
54439
+ isInterrupt: false,
54440
+ hasSource: merged.meta?.source != null,
54441
+ effectiveText: merged.text
54442
+ })) {
54443
+ return;
54444
+ }
54445
+ const key = chatKey2(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null);
54446
+ trackDelivery(deliveryQueue, key, merged, Date.now(), merged.messageId != null ? String(merged.messageId) : null);
54447
+ }
54352
54448
  async function redeliverStrandedInbound(p) {
54353
54449
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
54354
54450
  process.stderr.write(`telegram gateway: inbound strand (no enqueue ack) key=${p.key} \u2014 re-clearing composer + re-delivering
@@ -54455,7 +54551,8 @@ var ipcServer = createIpcServer({
54455
54551
  pendingInboundBuffer,
54456
54552
  inboundSpool: inboundSpool ?? null,
54457
54553
  pendingPermissionBuffer,
54458
- client: client3
54554
+ client: client3,
54555
+ onUserInboundDelivered: trackRedeliveredInbound
54459
54556
  });
54460
54557
  } else {
54461
54558
  const pending2 = pendingInboundBuffer.drain(client3.agentName);
@@ -54463,6 +54560,7 @@ var ipcServer = createIpcServer({
54463
54560
  try {
54464
54561
  client3.send(msg);
54465
54562
  inboundSpool?.ack(msg);
54563
+ trackRedeliveredInbound(msg);
54466
54564
  } catch (err) {
54467
54565
  process.stderr.write(`telegram gateway: pending-inbound drain failed agent=${client3.agentName} source=${msg.meta?.source ?? "-"}: ${err.message}
54468
54566
  `);
@@ -54646,7 +54744,6 @@ var ipcServer = createIpcServer({
54646
54744
  onPermissionRequest(_client, msg) {
54647
54745
  const { requestId, toolName, description, inputPreview } = msg;
54648
54746
  pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() });
54649
- const access = loadAccess();
54650
54747
  const text = formatPermissionCardBody({
54651
54748
  toolName,
54652
54749
  inputPreview,
@@ -54656,20 +54753,14 @@ var ipcServer = createIpcServer({
54656
54753
  const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null;
54657
54754
  const keyboard = buildPermissionActionRow(requestId, showAlways);
54658
54755
  const activeTurn = currentTurn;
54659
- const permTopic = resolveAgentOutboundTopic({
54660
- kind: "permission",
54661
- turnInitiated: activeTurn != null,
54662
- originThreadId: activeTurn?.sessionThreadId
54663
- });
54664
- const permSupergroup = resolveAgentSupergroupChatId();
54665
- for (const chat_id of access.allowFrom) {
54666
- const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup });
54667
- bot.api.sendMessage(chat_id, text, {
54756
+ const targets = resolvePermissionCardTargets();
54757
+ for (const { chatId, threadId } of targets) {
54758
+ retryWithThreadFallback(robustApiCall, (tid) => bot.api.sendMessage(chatId, text, {
54668
54759
  parse_mode: "HTML",
54669
54760
  reply_markup: keyboard,
54670
- ...permThread != null ? { message_thread_id: permThread } : {}
54671
- }).catch((e) => {
54672
- process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}
54761
+ ...tid != null ? { message_thread_id: tid } : {}
54762
+ }), { threadId, chat_id: chatId, verb: "permission_request" }).catch((e) => {
54763
+ process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}
54673
54764
  `);
54674
54765
  });
54675
54766
  }
@@ -55025,7 +55116,7 @@ if (!STATIC) {
55025
55116
  if (d)
55026
55117
  markClaudeBusyForInbound(m);
55027
55118
  return d;
55028
- }, inboundSpool);
55119
+ }, inboundSpool, trackRedeliveredInbound);
55029
55120
  if (r != null && r.redelivered > 0) {
55030
55121
  process.stderr.write(`telegram gateway: idle-drain flushed ${r.redelivered}/${r.drained} buffered inbound for ${selfAgent}${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ""}
55031
55122
  `);
@@ -1064,7 +1064,19 @@ try {
1064
1064
  const pending = findLatestTurnIfInterrupted(turnsDb)
1065
1065
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
1066
1066
  if (pending != null && selfAgent) {
1067
- const kind = selectResumeBuilder(pending.ended_via)
1067
+ // 3h staleness failsafe (operator spec, 2026-06-03): never AUTO-resume
1068
+ // interrupted work older than RESUME_MAX_AGE_MS — selectResumeBuilder
1069
+ // downgrades a stale 'resume' to the passive 'report' so the user is told
1070
+ // ("I was working on X ~Nh ago") but nothing replays unprompted. Env
1071
+ // override SWITCHROOM_RESUME_MAX_AGE_MS (ms); set very high to disable.
1072
+ const RESUME_MAX_AGE_MS = (() => {
1073
+ const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS)
1074
+ return Number.isFinite(v) && v > 0 ? v : 10_800_000 // 3h
1075
+ })()
1076
+ const kind = selectResumeBuilder(pending.ended_via, {
1077
+ ageMs: Math.max(0, Date.now() - pending.started_at),
1078
+ maxAgeMs: RESUME_MAX_AGE_MS,
1079
+ })
1068
1080
  if (kind === 'resume') {
1069
1081
  bootResumeInbound = { agent: selfAgent, msg: buildResumeInterruptedInbound({ turn: pending }) }
1070
1082
  } else if (kind === 'report') {
@@ -1801,6 +1813,7 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
1801
1813
  return d
1802
1814
  },
1803
1815
  inboundSpool,
1816
+ trackRedeliveredInbound,
1804
1817
  )
1805
1818
  if (fr.redelivered > 0) {
1806
1819
  process.stderr.write(
@@ -1896,6 +1909,7 @@ function releaseTurnBufferGate(key: string): void {
1896
1909
  return d
1897
1910
  },
1898
1911
  inboundSpool,
1912
+ trackRedeliveredInbound,
1899
1913
  )
1900
1914
  if (fr.redelivered > 0) {
1901
1915
  process.stderr.write(
@@ -2240,6 +2254,47 @@ function resumeReactionAfterVerdict(): void {
2240
2254
  ?.setThinking()
2241
2255
  }
2242
2256
 
2257
+ /**
2258
+ * The recipient set for a permission card (the initial Approve/Deny card
2259
+ * AND the post-verdict resume message — they MUST route identically, so
2260
+ * both go through this one helper).
2261
+ *
2262
+ * Turn-initiated (the normal case — a permission gate fires mid-tool-use
2263
+ * with an active turn): send to the ORIGINATING chat+thread. For a
2264
+ * supergroup-owned agent working in a forum topic that is the supergroup +
2265
+ * the topic, so the card lands IN the topic the operator asked from (e.g.
2266
+ * marko's "CRM (Brevo)" topic) — not the operator's DM. For a DM agent the
2267
+ * originating chat IS the operator's DM (thread-less), unchanged.
2268
+ *
2269
+ * No active turn (cron / background / a swept turn at TTL): fall back to the
2270
+ * configured operator DMs (`allowFrom`), thread-stripped via
2271
+ * `topicForRecipient` so a DM never gets a `message_thread_id` (the 400
2272
+ * "message thread not found" → auto-deny wedge, #2096).
2273
+ *
2274
+ * Before this helper the INITIAL card emitter iterated `allowFrom`
2275
+ * unconditionally, so a supergroup card could only ever reach operator DMs —
2276
+ * the topic chat id is never in `allowFrom`. The resume message already
2277
+ * routed correctly; the card now matches it (marko, 2026-06-03).
2278
+ */
2279
+ function resolvePermissionCardTargets(): Array<{ chatId: string; threadId: number | undefined }> {
2280
+ const turn = currentTurn
2281
+ if (turn != null) {
2282
+ return [{ chatId: turn.sessionChatId, threadId: turn.sessionThreadId }]
2283
+ }
2284
+ const sg = resolveAgentSupergroupChatId()
2285
+ const topic = resolveAgentOutboundTopic({
2286
+ kind: 'permission',
2287
+ turnInitiated: false,
2288
+ originThreadId: undefined,
2289
+ })
2290
+ // allowFrom is normally operator DMs — attach the topic only to a
2291
+ // recipient that owns it (the supergroup), never a DM (marko wedge).
2292
+ return loadAccess().allowFrom.map(chatId => ({
2293
+ chatId,
2294
+ threadId: topicForRecipient({ recipientChatId: chatId, resolvedTopic: topic, supergroupChatId: sg }),
2295
+ }))
2296
+ }
2297
+
2243
2298
  /**
2244
2299
  * Post the agent-voiced "got your verdict — continuing" message the
2245
2300
  * instant the operator answers a permission card. Travels right beside
@@ -2269,24 +2324,7 @@ function postPermissionResumeMessage(opts: {
2269
2324
  action: opts.action,
2270
2325
  timeoutMinutes: opts.timeoutMinutes,
2271
2326
  })
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
- })()
2327
+ const targets = resolvePermissionCardTargets()
2290
2328
  for (const { chatId, threadId } of targets) {
2291
2329
  // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send
2292
2330
  void swallowingApiCall(
@@ -4110,6 +4148,7 @@ silencePoke.startTimer({
4110
4148
  return d
4111
4149
  },
4112
4150
  inboundSpool,
4151
+ trackRedeliveredInbound,
4113
4152
  )
4114
4153
  process.stderr.write(
4115
4154
  `telegram gateway: silence-poke framework-fallback ended wedged turn ` +
@@ -4139,6 +4178,45 @@ const _deliveryMachineTick = setInterval(() => {
4139
4178
  }, DELIVERY_MACHINE_TICK_MS)
4140
4179
  _deliveryMachineTick.unref?.()
4141
4180
 
4181
+ // Enrol a buffer-redelivered inbound in the deliver-until-acked queue so the
4182
+ // existing sweep re-delivers it until claude's `enqueue` ack lands. Wired into
4183
+ // EVERY redelivery path (bridgeUp drain, silence-poke fallback, flap/reply-gate
4184
+ // flushes) — `send` returning true only means the bytes reached the bridge, NOT
4185
+ // that claude consumed them. Right after a restart (esp. a slow MCP boot) the
4186
+ // inject can hit a not-ready session and be silently dropped, and nothing
4187
+ // retried it: the clerk 2026-06-03 lost-message incident. Mirrors the
4188
+ // live-delivery tracking at the handleInbound site (chatKey + messageId), so
4189
+ // DMs and supergroup forum topics are handled identically. Only real user
4190
+ // inbounds are tracked — shouldTrackDelivery excludes steer/interrupt/
4191
+ // synthetic-source/empty, which never produce an `enqueue` and would otherwise
4192
+ // re-deliver forever.
4193
+ function trackRedeliveredInbound(merged: InboundMessage): void {
4194
+ if (!DELIVERY_CONFIRM_ENABLED) return
4195
+ if (
4196
+ !shouldTrackDelivery({
4197
+ isSteering: false,
4198
+ isInterrupt: false,
4199
+ // Synthetic inbounds (cron / vault / handback / resume) carry a source
4200
+ // and are NOT tracked here — they enqueue under their own semantics, and
4201
+ // (for the resume synthetics) tracking them safely first needs the
4202
+ // resume builder to emit meta.message_id so the deliver-until-acked ack
4203
+ // matches its enqueue. Tracked separately as a follow-up (see PR notes).
4204
+ hasSource: merged.meta?.source != null,
4205
+ effectiveText: merged.text,
4206
+ })
4207
+ ) {
4208
+ return
4209
+ }
4210
+ const key = chatKey(merged.chatId, merged.threadId != null ? Number(merged.threadId) : null)
4211
+ trackDelivery(
4212
+ deliveryQueue,
4213
+ key,
4214
+ merged,
4215
+ Date.now(),
4216
+ merged.messageId != null ? String(merged.messageId) : null,
4217
+ )
4218
+ }
4219
+
4142
4220
  // Re-deliver stranded inbounds until claude acks (the marko drop-wedge).
4143
4221
  // Every few seconds, re-send any inbound that was handed to claude but never
4144
4222
  // acked by an `enqueue` — it stranded unsubmitted in the composer. Re-clear
@@ -4376,6 +4454,11 @@ const ipcServer: IpcServer = createIpcServer({
4376
4454
  inboundSpool: inboundSpool ?? null,
4377
4455
  pendingPermissionBuffer,
4378
4456
  client,
4457
+ // Enrol each drained user inbound in the deliver-until-acked queue
4458
+ // so the 5s sweep re-delivers until claude's `enqueue` ack lands —
4459
+ // a socket-write into a still-booting session is NOT consumption
4460
+ // (clerk lost-message incident, 2026-06-03).
4461
+ onUserInboundDelivered: trackRedeliveredInbound,
4379
4462
  })
4380
4463
  } else {
4381
4464
  // Kill-switch fallback: imperative drain (parity with pre-cutover
@@ -4386,6 +4469,10 @@ const ipcServer: IpcServer = createIpcServer({
4386
4469
  try {
4387
4470
  client.send(msg)
4388
4471
  inboundSpool?.ack(msg)
4472
+ // Same enrol as the cutover drain path: a socket-write success is
4473
+ // not proof claude consumed it — enrol so the sweep re-delivers
4474
+ // until `enqueue` (clerk lost-message incident, 2026-06-03).
4475
+ trackRedeliveredInbound(msg)
4389
4476
  } catch (err) {
4390
4477
  process.stderr.write(
4391
4478
  `telegram gateway: pending-inbound drain failed agent=${client.agentName} ` +
@@ -4665,7 +4752,6 @@ const ipcServer: IpcServer = createIpcServer({
4665
4752
  onPermissionRequest(_client: IpcClient, msg: PermissionRequestForward) {
4666
4753
  const { requestId, toolName, description, inputPreview } = msg
4667
4754
  pendingPermissions.set(requestId, { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now() })
4668
- const access = loadAccess()
4669
4755
  // Natural-language card body — a plain sentence ("Gymbro wants to
4670
4756
  // edit: supplement-log.md" + a why-line), never a raw tool id.
4671
4757
  // The operator sees what is being requested and why at a glance.
@@ -4685,42 +4771,34 @@ const ipcServer: IpcServer = createIpcServer({
4685
4771
  // two-button row only.
4686
4772
  const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
4687
4773
  const keyboard = buildPermissionActionRow(requestId, showAlways)
4688
- // PR4b emitter sweep supergroup-mode permission card routing.
4689
- // Per CPO #3 the design is "turn-initiated requests follow the
4690
- // conversation topic; background requests go to admin alias."
4691
- // Permission requests come from the bridge mid-tool-use, so they
4692
- // are always turn-initiated in practice the currently active
4693
- // turn's sessionThreadId is the originating topic. Fall back to
4694
- // admin alias when no active turn (cron / background path).
4695
- // Fleet-shared / DM agents see `undefined` no
4696
- // `message_thread_id` is added → behavior unchanged.
4697
- // currentTurn is the singleton "claude is currently on this turn"
4698
- // pointer — per Framing 1 / PR3b scope-discovery, claude
4699
- // serializes so there's exactly one (or zero) active turn at any
4700
- // moment. When set, the permission request is in-flight for that
4701
- // turn and follows the originating topic.
4774
+ // Route the card to the SAME place the post-verdict resume message
4775
+ // lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
4776
+ // there's an active turn so a supergroup agent's card appears IN the
4777
+ // topic the operator asked from (marko's "CRM (Brevo)"), not the
4778
+ // operator DM else the configured operator DMs, thread-stripped. The
4779
+ // old code iterated `allowFrom` unconditionally, so a supergroup card
4780
+ // could only ever reach operator DMs (the topic chat id is never in
4781
+ // `allowFrom`) (marko, 2026-06-03).
4702
4782
  const activeTurn = currentTurn
4703
- const permTopic = resolveAgentOutboundTopic({
4704
- kind: 'permission',
4705
- turnInitiated: activeTurn != null,
4706
- originThreadId: activeTurn?.sessionThreadId,
4707
- })
4708
- const permSupergroup = resolveAgentSupergroupChatId()
4709
- for (const chat_id of access.allowFrom) {
4710
- // parse_mode=HTML pairs with formatPermissionCardBody (#1790)
4711
- // so the <b>/<i> tags render as formatting.
4712
- // The resolved topic is valid only in the agent's supergroup — attach
4713
- // it ONLY when this recipient IS that supergroup. allowFrom DMs get the
4714
- // card thread-less; attaching a topic to a DM yields 400 "message thread
4715
- // not found" → card never arrives → auto-deny → wedge (marko 2026-06-02).
4716
- const permThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: permTopic, supergroupChatId: permSupergroup })
4717
- // allow-raw-bot-api: permission-request keyboard fan-out; topic-aware opts
4718
- void bot.api.sendMessage(chat_id, text, {
4719
- parse_mode: 'HTML',
4720
- reply_markup: keyboard,
4721
- ...(permThread != null ? { message_thread_id: permThread } : {}),
4722
- }).catch(e => {
4723
- process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
4783
+ const targets = resolvePermissionCardTargets()
4784
+ for (const { chatId, threadId } of targets) {
4785
+ // parse_mode=HTML pairs with formatPermissionCardBody (#1790) so the
4786
+ // <b>/<i> tags render. retryWithThreadFallback: if the topic was
4787
+ // deleted/recreated (stale thread id → 400 "message thread not
4788
+ // found"), re-send thread-less into the main chat so the card still
4789
+ // ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
4790
+ // allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware send
4791
+ void retryWithThreadFallback<{ message_id: number }>(
4792
+ robustApiCall,
4793
+ (tid) =>
4794
+ bot.api.sendMessage(chatId, text, {
4795
+ parse_mode: 'HTML',
4796
+ reply_markup: keyboard,
4797
+ ...(tid != null ? { message_thread_id: tid } : {}),
4798
+ }),
4799
+ { threadId, chat_id: chatId, verb: 'permission_request' },
4800
+ ).catch(e => {
4801
+ process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
4724
4802
  })
4725
4803
  }
4726
4804
  // Park the turn's status reaction on 🙏 (awaiting your tap) and
@@ -5303,6 +5381,7 @@ if (!STATIC) {
5303
5381
  return d
5304
5382
  },
5305
5383
  inboundSpool,
5384
+ trackRedeliveredInbound,
5306
5385
  )
5307
5386
  if (r != null && r.redelivered > 0) {
5308
5387
  process.stderr.write(
@@ -45,6 +45,15 @@ export interface DispatchCtx {
45
45
  readonly client?: IpcClient
46
46
  /** Optional log sink — default stderr. Test hook. */
47
47
  readonly log?: (line: string) => void
48
+ /**
49
+ * Optional: enrol a drained+redelivered inbound in the deliver-until-acked
50
+ * queue. The bridgeUp drain's socket-write "success" is NOT proof claude
51
+ * consumed the message — right after a restart (esp. with a slow MCP boot)
52
+ * the inject can hit a not-ready session and be dropped. Wiring this makes
53
+ * the existing 5s sweep re-deliver until claude's `enqueue` ack lands.
54
+ * (clerk lost-message incident, 2026-06-03.)
55
+ */
56
+ readonly onUserInboundDelivered?: (merged: InboundMessage) => void
48
57
  }
49
58
 
50
59
  const enabled = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== '0'
@@ -103,6 +112,9 @@ function dispatchOne(effect: Effect, ctx: DispatchCtx): void {
103
112
  ctx.selfAgent,
104
113
  send,
105
114
  ctx.inboundSpool ?? undefined,
115
+ ctx.onUserInboundDelivered
116
+ ? (merged) => ctx.onUserInboundDelivered!(merged)
117
+ : undefined,
106
118
  )
107
119
  if (result.drained > 0) {
108
120
  log(
@@ -87,6 +87,14 @@ export function redeliverBufferedInbound(
87
87
  agent: string,
88
88
  send: (msg: InboundMessage) => boolean,
89
89
  spool?: InboundSpool,
90
+ // Called once per merged group on CONFIRMED delivery (after spool.ack).
91
+ // The caller uses it to enrol the redelivered inbound in the
92
+ // deliver-until-acked queue (`trackDelivery`) so it is re-sent until
93
+ // claude's `enqueue` ack lands — closing the restart boot-race where a
94
+ // socket-write "succeeds" into a not-ready session and the message is
95
+ // silently dropped (clerk 2026-06-03). `send` returning true only means
96
+ // the bytes reached the bridge, NOT that claude consumed them.
97
+ onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
90
98
  ): { drained: number; redelivered: number; rebuffered: number } {
91
99
  const pending = buffer.drain(agent)
92
100
  let redelivered = 0
@@ -110,6 +118,10 @@ export function redeliverBufferedInbound(
110
118
  // originals are, so we ack by original identity.
111
119
  for (const o of originals) spool?.ack(o)
112
120
  redelivered += originals.length
121
+ // Enrol in the deliver-until-acked queue (caller's hook). A bare
122
+ // socket-write success is NOT proof claude consumed it; the queue's
123
+ // sweep re-delivers until the `enqueue` ack lands.
124
+ onDelivered?.(merged, originals)
113
125
  } else {
114
126
  // Re-buffer the originals (not the merged synthetic) so the spool
115
127
  // identity is preserved and the next drain re-merges them losslessly.
@@ -258,11 +270,15 @@ export function idleDrainTick(
258
270
  isBridgeAlive: () => boolean,
259
271
  send: (msg: InboundMessage) => boolean,
260
272
  spool?: InboundSpool,
273
+ // Forwarded to redeliverBufferedInbound so the post-flap-settle drain also
274
+ // enrols redelivered inbounds in the deliver-until-acked queue (parity with
275
+ // the bridgeUp drain — clerk lost-message incident, 2026-06-03).
276
+ onDelivered?: (merged: InboundMessage, originals: InboundMessage[]) => void,
261
277
  ): { drained: number; redelivered: number; rebuffered: number } | null {
262
278
  if (!agent) return null
263
279
  if (buffer.depth(agent) === 0) return null
264
280
  if (!isBridgeAlive()) return null
265
- return redeliverBufferedInbound(buffer, agent, send, spool)
281
+ return redeliverBufferedInbound(buffer, agent, send, spool, onDelivered)
266
282
  }
267
283
 
268
284
  export function createPendingInboundBuffer(
@@ -172,9 +172,25 @@ export function buildResumeWatchdogReportInbound(
172
172
  */
173
173
  export function selectResumeBuilder(
174
174
  endedVia: TurnEndedVia | null,
175
+ // 3h staleness failsafe (operator spec, 2026-06-03): when the interrupted
176
+ // turn is older than `maxAgeMs`, an AUTO-resume is downgraded to the passive
177
+ // `report` — silently re-injecting hours-old work could act on long-stale
178
+ // context (a tax figure, a "send it" the user has moved on from). Pass both
179
+ // to enable; omit (default) keeps the legacy blanket-resume behaviour.
180
+ opts?: { ageMs?: number; maxAgeMs?: number },
175
181
  ): 'resume' | 'report' | null {
176
- if (endedVia === 'timeout') return 'report'
177
- if (endedVia === 'restart' || endedVia === 'sigterm' || endedVia === 'unknown') return 'resume'
178
- if (endedVia == null) return 'resume' // still-open at boot = killed mid-flight
179
- return null
182
+ let kind: 'resume' | 'report' | null
183
+ if (endedVia === 'timeout') kind = 'report'
184
+ else if (endedVia === 'restart' || endedVia === 'sigterm' || endedVia === 'unknown') kind = 'resume'
185
+ else if (endedVia == null) kind = 'resume' // still-open at boot = killed mid-flight
186
+ else kind = null
187
+ if (
188
+ kind === 'resume' &&
189
+ opts?.ageMs != null &&
190
+ opts?.maxAgeMs != null &&
191
+ opts.ageMs > opts.maxAgeMs
192
+ ) {
193
+ return 'report' // too old to safely auto-resume — passive notice only
194
+ }
195
+ return kind
180
196
  }
@@ -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
@@ -220,6 +220,33 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
220
220
  expect(calls).toBe(0)
221
221
  })
222
222
 
223
+ // onDelivered: the deliver-until-acked enrol hook (clerk lost-message
224
+ // incident 2026-06-03). A socket-write "success" is not proof claude
225
+ // consumed it; the caller uses onDelivered to enrol the redelivered inbound
226
+ // in the deliver-until-acked queue so the sweep re-delivers until `enqueue`.
227
+ it('calls onDelivered for each CONFIRMED-delivered group (per merged identity)', () => {
228
+ const buf = createPendingInboundBuffer({ log: () => {} })
229
+ buf.push('klanker', inbound('user', 1))
230
+ buf.push('klanker', inbound('cron', 2)) // source-tagged → its own group
231
+ const delivered: number[] = []
232
+ const r = redeliverBufferedInbound(buf, 'klanker', () => true, undefined, (merged) => {
233
+ delivered.push(merged.messageId as number)
234
+ })
235
+ expect(r.redelivered).toBe(2)
236
+ expect(delivered).toEqual([1, 2]) // fired once per group, carrying the merged identity
237
+ })
238
+
239
+ it('does NOT call onDelivered for a group that failed to send (re-buffered, not enrolled)', () => {
240
+ const buf = createPendingInboundBuffer({ log: () => {} })
241
+ buf.push('klanker', inbound('user', 1))
242
+ const delivered: number[] = []
243
+ const r = redeliverBufferedInbound(buf, 'klanker', () => false, undefined, (m) =>
244
+ delivered.push(m.messageId as number),
245
+ )
246
+ expect(r.rebuffered).toBe(1)
247
+ expect(delivered).toEqual([]) // never enrolled — buffer/spool still own it
248
+ })
249
+
223
250
  it('only touches the named agent', () => {
224
251
  const buf = createPendingInboundBuffer({ log: () => {} })
225
252
  buf.push('klanker', inbound('user', 1))
@@ -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
+ })
@@ -87,6 +87,35 @@ describe('naturalAction — MCP tools', () => {
87
87
  'list files (Google Workspace)',
88
88
  )
89
89
  })
90
+
91
+ // Clarity fix: REST-wrapper MCP tools (brevo/meta/postiz via rest-server.mjs)
92
+ // take a `path` — surface it so "post (Brevo)" becomes "POST /smtp/email
93
+ // (Brevo)" and the operator can see WHICH endpoint is being written.
94
+ test('REST-wrapper write names the endpoint with an uppercased HTTP verb', () => {
95
+ expect(
96
+ naturalAction('mcp__brevo__post', JSON.stringify({ path: '/smtp/email', body: { to: 'x' } })),
97
+ ).toBe('POST /smtp/email (Brevo)')
98
+ expect(
99
+ naturalAction('mcp__brevo__put', JSON.stringify({ path: '/contacts/123', body: {} })),
100
+ ).toBe('PUT /contacts/123 (Brevo)')
101
+ })
102
+
103
+ test('REST-wrapper read surfaces the path too', () => {
104
+ expect(
105
+ naturalAction('mcp__brevo__get', JSON.stringify({ path: '/contacts', query: { limit: 10 } })),
106
+ ).toBe('GET /contacts (Brevo)')
107
+ })
108
+
109
+ test('falls back to the plain verb phrase when there is no resource key', () => {
110
+ // No path → today's behavior, unchanged (defensive for unknown shapes).
111
+ expect(naturalAction('mcp__brevo__post', undefined)).toBe('post (Brevo)')
112
+ expect(naturalAction('mcp__brevo__post', JSON.stringify({ foo: 1 }))).toBe('post (Brevo)')
113
+ })
114
+
115
+ test('internal REST-ish tool is NOT endpoint-enriched (stays a bare verb)', () => {
116
+ // hostd is internal → no "(Server)" tag, no path enrichment.
117
+ expect(naturalAction('mcp__hostd__do_thing', JSON.stringify({ path: '/x' }))).toBe('do thing')
118
+ })
90
119
  })
91
120
 
92
121
  describe('formatPermissionCardBody', () => {
@@ -156,6 +185,56 @@ describe('formatPermissionCardBody', () => {
156
185
  })
157
186
  expect(body).toContain('why: <i>first second paragraph</i>')
158
187
  })
188
+
189
+ // Clarity fix: the card gains a third "↳" line summarizing the REST
190
+ // payload so the operator can see WHAT is being written, not just the
191
+ // endpoint. Values are redaction-passed + truncated; nested objects show
192
+ // as a bare key name.
193
+ test('REST write card: endpoint in the title + a payload summary line', () => {
194
+ const body = formatPermissionCardBody({
195
+ toolName: 'mcp__brevo__post',
196
+ inputPreview: JSON.stringify({
197
+ path: '/smtp/email',
198
+ body: { subject: 'Priority access', templateId: 12, to: [{ email: 'lisa@example.com' }] },
199
+ }),
200
+ description: 'HIGH RISK: write to the brevo API (POST).',
201
+ agentName: 'marko',
202
+ })
203
+ const lines = body.split('\n')
204
+ expect(lines[0]).toBe('🔐 <b>Marko</b> wants to POST /smtp/email (Brevo)')
205
+ expect(lines[1]).toBe('why: <i>HIGH RISK: write to the brevo API (POST).</i>')
206
+ // Third line: scalar keys show value; the nested `to` array shows key-only.
207
+ expect(lines[2]).toContain('↳')
208
+ expect(lines[2]).toContain('subject: Priority access')
209
+ expect(lines[2]).toContain('templateId: 12')
210
+ expect(lines[2]).toContain('to') // key-only, not the email object dumped
211
+ expect(lines[2]).not.toContain('lisa@example.com')
212
+ })
213
+
214
+ test('no payload → no third line (DM / non-REST cards unchanged)', () => {
215
+ const body = formatPermissionCardBody({
216
+ toolName: 'Edit',
217
+ inputPreview: JSON.stringify({ file_path: '/a/b.md' }),
218
+ description: 'edit it',
219
+ agentName: 'clerk',
220
+ })
221
+ expect(body.split('\n')).toHaveLength(2)
222
+ expect(body).not.toContain('↳')
223
+ })
224
+
225
+ test('redaction is load-bearing: a token in the payload is masked, never shown', () => {
226
+ // Build the fake token at runtime so the source file never holds a
227
+ // contiguous token literal (repo push-protection rule).
228
+ const fakeToken = 'sk-ant-' + 'api03-' + 'A'.repeat(48)
229
+ const body = formatPermissionCardBody({
230
+ toolName: 'mcp__brevo__post',
231
+ inputPreview: JSON.stringify({ path: '/contacts', body: { apiKey: fakeToken, name: 'Lisa' } }),
232
+ description: 'create a contact',
233
+ agentName: 'marko',
234
+ })
235
+ expect(body).not.toContain(fakeToken)
236
+ expect(body).toContain('name: Lisa') // benign value still surfaces
237
+ })
159
238
  })
160
239
 
161
240
  describe('describeGrant — phrased from the chosen scope', () => {
@@ -179,4 +179,23 @@ describe('selectResumeBuilder', () => {
179
179
  expect(selectResumeBuilder(endedVia)).toBe(expected)
180
180
  })
181
181
  }
182
+
183
+ // 3h staleness failsafe (operator spec, 2026-06-03).
184
+ const MAX = 10_800_000 // 3h
185
+ it('downgrades a fresh resume to report when older than maxAgeMs (no auto-resume of stale work)', () => {
186
+ expect(selectResumeBuilder('restart', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
187
+ expect(selectResumeBuilder(null, { ageMs: MAX + 60_000, maxAgeMs: MAX })).toBe('report')
188
+ })
189
+ it('keeps resume when within maxAgeMs', () => {
190
+ expect(selectResumeBuilder('restart', { ageMs: MAX - 1, maxAgeMs: MAX })).toBe('resume')
191
+ expect(selectResumeBuilder('sigterm', { ageMs: 1000, maxAgeMs: MAX })).toBe('resume')
192
+ })
193
+ it('age cap never UPGRADES — report/null stay as-is regardless of age', () => {
194
+ expect(selectResumeBuilder('timeout', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe('report')
195
+ expect(selectResumeBuilder('stop', { ageMs: MAX + 1, maxAgeMs: MAX })).toBe(null)
196
+ })
197
+ it('legacy behaviour preserved when age/maxAge omitted (blanket resume)', () => {
198
+ expect(selectResumeBuilder('restart')).toBe('resume')
199
+ expect(selectResumeBuilder('restart', { ageMs: MAX + 1 })).toBe('resume') // needs BOTH to cap
200
+ })
182
201
  })