switchroom 0.13.42 → 0.13.44

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.
@@ -27303,8 +27303,15 @@ function checkAclByAgent(config, agentName, key) {
27303
27303
  return { allow: true };
27304
27304
  }
27305
27305
  }
27306
- const mcpServers = agentConfig.mcp_servers ?? {};
27307
- for (const mcpEntry of Object.values(mcpServers)) {
27306
+ const cfgWithProfiles = config;
27307
+ const profileName = agentConfig.extends;
27308
+ const profileMcp = profileName != null && profileName.length > 0 ? cfgWithProfiles.profiles?.[profileName]?.mcp_servers ?? {} : {};
27309
+ const effectiveMcp = {
27310
+ ...cfgWithProfiles.defaults?.mcp_servers ?? {},
27311
+ ...profileMcp,
27312
+ ...agentConfig.mcp_servers ?? {}
27313
+ };
27314
+ for (const mcpEntry of Object.values(effectiveMcp)) {
27308
27315
  if (!mcpEntry || typeof mcpEntry !== "object")
27309
27316
  continue;
27310
27317
  const declared = mcpEntry.secrets;
@@ -47756,8 +47763,8 @@ var {
47756
47763
  } = import__.default;
47757
47764
 
47758
47765
  // src/build-info.ts
47759
- var VERSION = "0.13.42";
47760
- var COMMIT_SHA = "915bf972";
47766
+ var VERSION = "0.13.44";
47767
+ var COMMIT_SHA = "fa99d4de";
47761
47768
 
47762
47769
  // src/cli/agent.ts
47763
47770
  init_source();
@@ -13055,8 +13055,15 @@ function checkAclByAgent(config, agentName, key) {
13055
13055
  return { allow: true };
13056
13056
  }
13057
13057
  }
13058
- const mcpServers = agentConfig.mcp_servers ?? {};
13059
- for (const mcpEntry of Object.values(mcpServers)) {
13058
+ const cfgWithProfiles = config;
13059
+ const profileName = agentConfig.extends;
13060
+ const profileMcp = profileName != null && profileName.length > 0 ? cfgWithProfiles.profiles?.[profileName]?.mcp_servers ?? {} : {};
13061
+ const effectiveMcp = {
13062
+ ...cfgWithProfiles.defaults?.mcp_servers ?? {},
13063
+ ...profileMcp,
13064
+ ...agentConfig.mcp_servers ?? {}
13065
+ };
13066
+ for (const mcpEntry of Object.values(effectiveMcp)) {
13060
13067
  if (!mcpEntry || typeof mcpEntry !== "object")
13061
13068
  continue;
13062
13069
  const declared = mcpEntry.secrets;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.42",
3
+ "version": "0.13.44",
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": {
@@ -92,6 +92,22 @@ export interface AnswerStreamConfig {
92
92
  message_thread_id?: number
93
93
  link_preview_options?: { is_disabled: boolean }
94
94
  reply_parameters?: { message_id: number }
95
+ /**
96
+ * Distinguishes a streaming open (`'stream'` — silent edits-in-place
97
+ * preview, default) from the turn-end materialize (`'materialize'` —
98
+ * the user-facing final answer that should ping the device exactly
99
+ * once, matching beat-5 of the conversational-pacing contract).
100
+ *
101
+ * Used by the gateway wrapper in `gateway.ts` to set
102
+ * `disable_notification: true` for 'stream' purpose only —
103
+ * materialize lets the platform default (notify) through. Without
104
+ * this distinction, either every send pings (the original #1672
105
+ * bug, two device pings per multi-step turn — see
106
+ * `over-ping-safety-net.ts`) or none does (the over-correction
107
+ * where text-only short turns silently land and the user has no
108
+ * notification to know the answer arrived).
109
+ */
110
+ purpose?: 'stream' | 'materialize'
95
111
  },
96
112
  ) => Promise<{ message_id: number }>
97
113
  editMessageText: (
@@ -344,10 +360,14 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
344
360
  throw err
345
361
  }
346
362
  } else {
347
- // First send — capture message_id; check generation for supersession
363
+ // First send — capture message_id; check generation for supersession.
364
+ // purpose: 'stream' tells the gateway wrapper this is the visible
365
+ // preview opener — silent (no device ping). The turn-end materialize
366
+ // path uses 'materialize' to allow the platform-default ping.
348
367
  const sendParams: Parameters<typeof sendMessage>[2] = {
349
368
  parse_mode: 'HTML',
350
369
  link_preview_options: { is_disabled: true },
370
+ purpose: 'stream',
351
371
  }
352
372
  if (threadId != null) sendParams.message_thread_id = threadId
353
373
  if (replyToMessageId != null) sendParams.reply_parameters = { message_id: replyToMessageId }
@@ -495,10 +515,16 @@ export function createAnswerStream(config: AnswerStreamConfig): AnswerStreamHand
495
515
  return undefined
496
516
  }
497
517
 
498
- // Always send a fresh message for push notification
518
+ // Always send a fresh message for push notification.
519
+ // purpose: 'materialize' tells the gateway wrapper to allow the
520
+ // platform-default device ping through (vs the 'stream' opener
521
+ // which is forced silent). materialize() is the turn-end final-
522
+ // answer surface — beat 5 of the conversational-pacing contract
523
+ // — and MUST ping so the user knows the answer landed.
499
524
  const sendParams: Parameters<typeof sendMessage>[2] = {
500
525
  parse_mode: 'HTML',
501
526
  link_preview_options: { is_disabled: true },
527
+ purpose: 'materialize',
502
528
  }
503
529
  if (threadId != null) sendParams.message_thread_id = threadId
504
530
  // Don't quote-reply on materialize — the draft stream already established
@@ -37624,7 +37624,7 @@ function tick2(now) {
37624
37624
  import { existsSync as existsSync5, readFileSync as readFileSync3, writeFileSync as writeFileSync3, unlinkSync, mkdirSync as mkdirSync5 } from "node:fs";
37625
37625
  import { dirname as dirname6, join as join6 } from "node:path";
37626
37626
  import { homedir as homedir2 } from "node:os";
37627
- var SILENT_END_MAX_RETRIES = 1;
37627
+ var SILENT_END_MAX_RETRIES = 2;
37628
37628
  function resolveStateDir(deps) {
37629
37629
  if (deps?.stateDir != null)
37630
37630
  return deps.stateDir;
@@ -37862,7 +37862,8 @@ function createAnswerStream(config) {
37862
37862
  } else {
37863
37863
  const sendParams = {
37864
37864
  parse_mode: "HTML",
37865
- link_preview_options: { is_disabled: true }
37865
+ link_preview_options: { is_disabled: true },
37866
+ purpose: "stream"
37866
37867
  };
37867
37868
  if (threadId != null)
37868
37869
  sendParams.message_thread_id = threadId;
@@ -37978,7 +37979,8 @@ function createAnswerStream(config) {
37978
37979
  }
37979
37980
  const sendParams = {
37980
37981
  parse_mode: "HTML",
37981
- link_preview_options: { is_disabled: true }
37982
+ link_preview_options: { is_disabled: true },
37983
+ purpose: "materialize"
37982
37984
  };
37983
37985
  if (threadId != null)
37984
37986
  sendParams.message_thread_id = threadId;
@@ -48730,10 +48732,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48730
48732
  }
48731
48733
 
48732
48734
  // ../src/build-info.ts
48733
- var VERSION = "0.13.42";
48734
- var COMMIT_SHA = "915bf972";
48735
- var COMMIT_DATE = "2026-05-25T08:37:49Z";
48736
- var LATEST_PR = 1808;
48735
+ var VERSION = "0.13.44";
48736
+ var COMMIT_SHA = "fa99d4de";
48737
+ var COMMIT_DATE = "2026-05-25T12:04:09Z";
48738
+ var LATEST_PR = 1816;
48737
48739
  var COMMITS_AHEAD_OF_TAG = 0;
48738
48740
 
48739
48741
  // gateway/boot-version.ts
@@ -50457,7 +50459,15 @@ var STREAM_THROTTLE_MS_OVERRIDE = (() => {
50457
50459
  return Number.isFinite(n) && n >= 0 ? n : undefined;
50458
50460
  })();
50459
50461
  var TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled();
50460
- var ANSWER_STREAM_VISIBLE_ENABLED = process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM === "1" || process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM === "true";
50462
+ var ANSWER_STREAM_VISIBLE_ENABLED = (() => {
50463
+ const raw = process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM;
50464
+ if (raw == null)
50465
+ return true;
50466
+ const v = raw.trim().toLowerCase();
50467
+ if (v === "0" || v === "false" || v === "off" || v === "no")
50468
+ return false;
50469
+ return true;
50470
+ })();
50461
50471
  var progressDriver = null;
50462
50472
  var unpinProgressCardForChat = null;
50463
50473
  var getPinnedProgressCardMessageId = null;
@@ -52662,14 +52672,16 @@ function handleSessionEvent(ev) {
52662
52672
  ...ANSWER_STREAM_VISIBLE_ENABLED ? { minInitialChars: 1 } : { sendMessageDraft: sendMessageDraftFn },
52663
52673
  sendMessage: async (chatId, text, params) => {
52664
52674
  const tid = params?.message_thread_id;
52675
+ const silent = params?.purpose !== "materialize";
52665
52676
  const msg = await robustApiCall(() => bot.api.sendMessage(chatId, text, {
52666
52677
  parse_mode: params?.parse_mode,
52678
+ disable_notification: silent,
52667
52679
  ...tid != null ? { message_thread_id: tid } : {},
52668
52680
  ...params?.link_preview_options != null ? { link_preview_options: params.link_preview_options } : {},
52669
52681
  ...params?.reply_parameters != null ? { reply_parameters: params.reply_parameters } : {}
52670
52682
  }), {
52671
52683
  chat_id: chatId,
52672
- verb: "answer-stream.sendMessage",
52684
+ verb: `answer-stream.sendMessage(${params?.purpose ?? "stream"})`,
52673
52685
  ...tid != null ? { threadId: tid } : {}
52674
52686
  });
52675
52687
  return { message_id: msg.message_id };
@@ -52785,24 +52797,32 @@ function handleSessionEvent(ev) {
52785
52797
  const streamedFinalText = turn.capturedText.join("").trim();
52786
52798
  if (ANSWER_STREAM_VISIBLE_ENABLED && !turn.replyCalled && streamedMsgId != null && streamedFinalText.length > 0) {
52787
52799
  turn.answerStream = null;
52788
- stream.stop();
52789
52800
  streamFinalizedAsAnswer = true;
52790
52801
  turn.finalAnswerDelivered = true;
52791
- try {
52792
- outboundDedup.record(turn.sessionChatId, turn.sessionThreadId, streamedFinalText, Date.now(), turn.registryKey ?? null);
52793
- } catch {}
52794
- if (HISTORY_ENABLED) {
52802
+ const oldStreamedMsgId = streamedMsgId;
52803
+ (async () => {
52804
+ let materializedId;
52795
52805
  try {
52796
- recordOutbound({
52797
- chat_id: turn.sessionChatId,
52798
- thread_id: turn.sessionThreadId ?? null,
52799
- message_ids: [streamedMsgId],
52800
- texts: [streamedFinalText]
52801
- });
52802
- } catch {}
52803
- }
52804
- process.stderr.write(`telegram gateway: answer-stream finalized as answer chat=${turn.sessionChatId} msg=${streamedMsgId} chars=${streamedFinalText.length}
52806
+ materializedId = await stream.materialize();
52807
+ } catch (err) {
52808
+ process.stderr.write(`telegram gateway: answer-stream materialize failed: ${err instanceof Error ? err.message : String(err)}
52809
+ `);
52810
+ return;
52811
+ }
52812
+ if (typeof materializedId !== "number" || !Number.isFinite(materializedId)) {
52813
+ process.stderr.write(`telegram gateway: answer-stream materialize returned no msgId chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} \u2014 ` + `preserving silent preview as the user's only copy
52805
52814
  `);
52815
+ return;
52816
+ }
52817
+ try {
52818
+ await bot.api.deleteMessage(turn.sessionChatId, oldStreamedMsgId);
52819
+ } catch (delErr) {
52820
+ process.stderr.write(`telegram gateway: answer-stream materialize-cleanup delete failed for msgId=${oldStreamedMsgId}: ${delErr instanceof Error ? delErr.message : String(delErr)}
52821
+ `);
52822
+ }
52823
+ process.stderr.write(`telegram gateway: answer-stream materialized as answer chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} newMsg=${materializedId} chars=${streamedFinalText.length}
52824
+ `);
52825
+ })();
52806
52826
  } else {
52807
52827
  turn.answerStream = null;
52808
52828
  stream.retract().catch((err) => {
@@ -3024,10 +3024,24 @@ const TURN_FLUSH_SAFETY_ENABLED = isTurnFlushSafetyEnabled()
3024
3024
  // shape — they see the answer materialise live. For longer waits,
3025
3025
  // the cross-turn pending-progress system (#1445/#1669) is the
3026
3026
  // canonical surface and DOES ping at the appropriate boundaries.
3027
- // Default OFF; flip per-agent via env to canary the new behaviour.
3028
- const ANSWER_STREAM_VISIBLE_ENABLED =
3029
- process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM === '1'
3030
- || process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM === 'true'
3027
+ //
3028
+ // 2026-05-25: default flipped ON after fleet-log audit showed
3029
+ // framework-fallback rate at ~19% of inbounds (target per
3030
+ // `reference/conversational-pacing.md`: <0.5%). Streaming the
3031
+ // model's text events live into the chat closes the
3032
+ // catastrophic-UX failure mode at the lane-of-first-defense level.
3033
+ // Aligned with the "chat IS the artifact" sub-principle (the user
3034
+ // sees a normal chat message that grows — no chrome, no parallel
3035
+ // widget). Override with SWITCHROOM_VISIBLE_ANSWER_STREAM=0 to
3036
+ // disable, e.g. for an agent that needs the legacy draft-only
3037
+ // behaviour during debugging.
3038
+ const ANSWER_STREAM_VISIBLE_ENABLED = (() => {
3039
+ const raw = process.env.SWITCHROOM_VISIBLE_ANSWER_STREAM
3040
+ if (raw == null) return true
3041
+ const v = raw.trim().toLowerCase()
3042
+ if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false
3043
+ return true
3044
+ })()
3031
3045
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3032
3046
  const progressDriver: any = null
3033
3047
  const unpinProgressCardForChat: ((chatId: string, threadId: number | undefined) => void) | null = null
@@ -6665,12 +6679,39 @@ function handleSessionEvent(ev: SessionEvent): void {
6665
6679
  // instead of crashing the answer-stream loop on a deleted
6666
6680
  // forum topic. answer-stream's own try/catch already
6667
6681
  // tolerates undefined returns from editMessageText.
6682
+ //
6683
+ // disable_notification gating by purpose (2026-05-25):
6684
+ //
6685
+ // - purpose='stream' (the live edit-in-place preview): SILENT.
6686
+ // Without disable_notification, the first text chunk that
6687
+ // opens the visible message device-pings, and then when the
6688
+ // model later calls the reply MCP tool, that reply pings
6689
+ // AGAIN (the over-ping safety net at gateway.ts:~4452 only
6690
+ // sees executeReply paths, not this direct sendMessage). Two
6691
+ // device pings per multi-step turn — the original Bug A.
6692
+ // Edits in place don't notify regardless (Telegram semantics).
6693
+ //
6694
+ // - purpose='materialize' (turn-end final-answer fresh send,
6695
+ // only fires for text-only turns where the stream IS the
6696
+ // answer): PING. The user reached for the agent and the
6697
+ // model produced an answer; per beat 5 of
6698
+ // `reference/conversational-pacing.md` the final answer MUST
6699
+ // ping the device exactly once. Without this carve-out, a
6700
+ // short text-only turn ("on it" being the whole response)
6701
+ // lands silently and the user has no notification to know
6702
+ // the answer arrived — the original over-correction.
6703
+ //
6704
+ // - purpose unset (defensive default): SILENT. Treat as
6705
+ // stream-purpose so we never accidentally fire a stray ping
6706
+ // from an unrecognised sendMessage callsite.
6668
6707
  sendMessage: async (chatId, text, params) => {
6669
6708
  const tid = params?.message_thread_id
6709
+ const silent = params?.purpose !== 'materialize'
6670
6710
  const msg = await robustApiCall(
6671
6711
  () =>
6672
6712
  bot.api.sendMessage(chatId, text, {
6673
6713
  parse_mode: params?.parse_mode,
6714
+ disable_notification: silent,
6674
6715
  ...(tid != null ? { message_thread_id: tid } : {}),
6675
6716
  ...(params?.link_preview_options != null
6676
6717
  ? { link_preview_options: params.link_preview_options }
@@ -6681,7 +6722,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6681
6722
  }),
6682
6723
  {
6683
6724
  chat_id: chatId,
6684
- verb: 'answer-stream.sendMessage',
6725
+ verb: `answer-stream.sendMessage(${params?.purpose ?? 'stream'})`,
6685
6726
  ...(tid != null ? { threadId: tid } : {}),
6686
6727
  },
6687
6728
  )
@@ -6872,13 +6913,30 @@ function handleSessionEvent(ev: SessionEvent): void {
6872
6913
  //
6873
6914
  // #869-Phase1 override: when `ANSWER_STREAM_VISIBLE_ENABLED` is
6874
6915
  // on, the stream is rendering a USER-VISIBLE message in the
6875
- // chat timeline. Retracting (delete) destroys content the user
6876
- // has been reading live the worst possible UX flicker. So
6877
- // when the stream is the de-facto final answer (model never
6878
- // called reply, captured text is substantive) we instead call
6879
- // `stream.stop()` to freeze it as the final state, record the
6880
- // outbound for history + dedup, mark the turn answered, and
6881
- // suppress the turn-flush IIFE downstream.
6916
+ // chat timeline. When the stream is the de-facto final answer
6917
+ // (model never called reply, captured text is substantive), we
6918
+ // need to:
6919
+ // 1. Send a FRESH pinged message via `stream.materialize()`
6920
+ // so the user gets a device notification beat 5 of the
6921
+ // conversational-pacing contract requires exactly one
6922
+ // ping per turn for the final answer. Without this,
6923
+ // text-only short turns ("on it" being the whole reply)
6924
+ // land silently and the user has no notification to know
6925
+ // the answer arrived (the failure caught by the
6926
+ // midturn-silent-dm UAT, 2026-05-25).
6927
+ // 2. Delete the silent streamed preview message so the user
6928
+ // doesn't see a duplicate (the streamed message in place
6929
+ // + the fresh materialized ping). materialize() handles
6930
+ // the fresh send but leaves the old streamed message_id
6931
+ // orphaned by design — we delete it explicitly here.
6932
+ //
6933
+ // The previous behavior (just `stream.stop()` to freeze the
6934
+ // streamed message in place) avoided the duplicate but also
6935
+ // skipped the ping. Materialize-and-delete trades a brief
6936
+ // visual "the streamed message is replaced by a fresh one"
6937
+ // (often imperceptible for short turns where the streaming
6938
+ // barely had time to register; mildly visible for longer
6939
+ // turns) in exchange for an always-correct turn-end ping.
6882
6940
  let streamFinalizedAsAnswer = false
6883
6941
  if (turn?.answerStream != null) {
6884
6942
  const stream = turn.answerStream
@@ -6891,36 +6949,80 @@ function handleSessionEvent(ev: SessionEvent): void {
6891
6949
  && streamedFinalText.length > 0
6892
6950
  ) {
6893
6951
  turn.answerStream = null
6894
- stream.stop()
6895
6952
  streamFinalizedAsAnswer = true
6896
6953
  turn.finalAnswerDelivered = true
6897
- // Record as canonical outbound so retries dedup against it
6898
- // and the SQLite history can surface it. Mirrors the
6899
- // hooks turn-flush + reply both run.
6900
- try {
6901
- outboundDedup.record(
6902
- turn.sessionChatId,
6903
- turn.sessionThreadId,
6904
- streamedFinalText,
6905
- Date.now(),
6906
- turn.registryKey ?? null,
6907
- )
6908
- } catch { /* best-effort */ }
6909
- if (HISTORY_ENABLED) {
6954
+ // Capture the old streamed message_id BEFORE materialize so
6955
+ // we can delete it after the fresh ping send. materialize()
6956
+ // overwrites `streamMsgId` internally with the new send's id;
6957
+ // without capturing here we'd lose the reference.
6958
+ const oldStreamedMsgId = streamedMsgId
6959
+ // Fire-and-forget materialize-and-delete sequence.
6960
+ //
6961
+ // Bookkeeping (dedup + history): handled inside
6962
+ // `materialize()` itself — see `answer-stream.ts:~548-549`
6963
+ // which calls the injected `recordDedup` + `recordOutbound`
6964
+ // callbacks with the NEW (fresh-send) message_id only after a
6965
+ // successful send. We deliberately do NOT pre-record here
6966
+ // doing so populates the same `outboundDedup` store that
6967
+ // materialize's internal `checkDedup` consults at
6968
+ // `answer-stream.ts:~510`, causing materialize to dedup-
6969
+ // suppress its own send (return undefined, no ping fires) —
6970
+ // the exact failure mode this PR exists to fix. Let
6971
+ // materialize own the bookkeeping; gateway only sequences the
6972
+ // operations.
6973
+ //
6974
+ // Delete gating: only run the cleanup `deleteMessage` if
6975
+ // materialize actually sent (returned a numeric sentId). If
6976
+ // it dedup-suppressed or threw, the streamed preview is the
6977
+ // user's only copy of the answer and MUST be preserved.
6978
+ void (async () => {
6979
+ let materializedId: number | undefined
6910
6980
  try {
6911
- recordOutbound({
6912
- chat_id: turn.sessionChatId,
6913
- thread_id: turn.sessionThreadId ?? null,
6914
- message_ids: [streamedMsgId],
6915
- texts: [streamedFinalText],
6916
- })
6917
- } catch { /* best-effort */ }
6918
- }
6919
- process.stderr.write(
6920
- `telegram gateway: answer-stream finalized as answer ` +
6921
- `chat=${turn.sessionChatId} msg=${streamedMsgId} ` +
6922
- `chars=${streamedFinalText.length}\n`,
6923
- )
6981
+ materializedId = await stream.materialize()
6982
+ } catch (err) {
6983
+ process.stderr.write(
6984
+ `telegram gateway: answer-stream materialize failed: ${
6985
+ err instanceof Error ? err.message : String(err)
6986
+ }\n`,
6987
+ )
6988
+ return
6989
+ }
6990
+ if (typeof materializedId !== 'number' || !Number.isFinite(materializedId)) {
6991
+ // materialize() returned undefined — either pendingText
6992
+ // was empty, the body was a silent marker (NO_REPLY /
6993
+ // HEARTBEAT_OK), or `checkDedup` suppressed it. In every
6994
+ // such case the streamed preview is the user's only copy
6995
+ // of the content; don't delete it.
6996
+ process.stderr.write(
6997
+ `telegram gateway: answer-stream materialize returned no msgId ` +
6998
+ `chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} — ` +
6999
+ `preserving silent preview as the user's only copy\n`,
7000
+ )
7001
+ return
7002
+ }
7003
+ // Materialize sent a fresh pinged message at materializedId.
7004
+ // Delete the silent streamed preview so the chat shows one
7005
+ // canonical message (the fresh pinged one) and not two with
7006
+ // duplicate content. Best-effort; failures (already gone,
7007
+ // permission denied) leave a brief visible duplicate which
7008
+ // we accept rather than retry-storming.
7009
+ try {
7010
+ // allow-raw-bot-api: cleanup delete of silent streamed preview
7011
+ await bot.api.deleteMessage(turn.sessionChatId, oldStreamedMsgId)
7012
+ } catch (delErr) {
7013
+ process.stderr.write(
7014
+ `telegram gateway: answer-stream materialize-cleanup ` +
7015
+ `delete failed for msgId=${oldStreamedMsgId}: ${
7016
+ delErr instanceof Error ? delErr.message : String(delErr)
7017
+ }\n`,
7018
+ )
7019
+ }
7020
+ process.stderr.write(
7021
+ `telegram gateway: answer-stream materialized as answer ` +
7022
+ `chat=${turn.sessionChatId} oldMsg=${oldStreamedMsgId} ` +
7023
+ `newMsg=${materializedId} chars=${streamedFinalText.length}\n`,
7024
+ )
7025
+ })()
6924
7026
  } else {
6925
7027
  turn.answerStream = null
6926
7028
  void stream.retract().catch((err) => {
@@ -62,7 +62,8 @@ import { scanTurnForFinalReply } from './silent-end-scan.mjs'
62
62
 
63
63
  // MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
64
64
  // (this hook is a standalone .mjs and can't import the TS module).
65
- const MAX_RETRIES = 1
65
+ // Bumped 1 → 2 on 2026-05-25 — see the matching doc-comment in silent-end.ts.
66
+ const MAX_RETRIES = 2
66
67
 
67
68
  function readStdin() {
68
69
  try {
@@ -56,8 +56,18 @@ export interface SilentEndDeps {
56
56
  * gives up. MUST stay in sync with `MAX_RETRIES` in the Stop hook
57
57
  * (`telegram-plugin/hooks/silent-end-interrupt-stop.mjs`) — the hook is a
58
58
  * standalone `.mjs` and can't import this module.
59
+ *
60
+ * 2026-05-25 bump from 1 → 2. With the original budget of 1, a model
61
+ * that stubbornly emitted `type:"text"` + `stop_reason:"end_turn"` twice
62
+ * in a row (instead of calling the reply tool) fell through to the
63
+ * gateway's 5-minute silence-poke framework-fallback. Second-retry
64
+ * cases now get a chance before the user-visible nudge fires. Memory:
65
+ * the Stop hook prompt itself is explicit ("only text sent through the
66
+ * reply tool is delivered. Send your final answer now"), so a second
67
+ * nudge isn't redundant — it's giving a different sample from the
68
+ * model under the same prompt.
59
69
  */
60
- export const SILENT_END_MAX_RETRIES = 1
70
+ export const SILENT_END_MAX_RETRIES = 2
61
71
 
62
72
  function resolveStateDir(deps?: SilentEndDeps): string {
63
73
  if (deps?.stateDir != null) return deps.stateDir
@@ -18,6 +18,7 @@
18
18
 
19
19
  import { describe, it, expect, beforeEach, afterEach } from 'vitest'
20
20
  import { spawnSync } from 'node:child_process'
21
+ import { SILENT_END_MAX_RETRIES } from '../silent-end.js'
21
22
  import {
22
23
  mkdtempSync,
23
24
  mkdirSync,
@@ -172,7 +173,10 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
172
173
  reply('ack', { disable_notification: true }),
173
174
  ])
174
175
  const statePath = join(stateDir, 'silent-end-pending.json')
175
- writeFileSync(statePath, JSON.stringify({ retryCount: 1, chatId: '111' }), 'utf8')
176
+ // Use the canonical ceiling so this test stays accurate as MAX_RETRIES evolves.
177
+ writeFileSync(statePath, JSON.stringify({
178
+ retryCount: SILENT_END_MAX_RETRIES, chatId: '111',
179
+ }), 'utf8')
176
180
 
177
181
  const r = runHook({
178
182
  event: { session_id: 's1', transcript_path: transcript },
@@ -183,7 +187,7 @@ describe('silent-end-interrupt-stop.mjs — integration', () => {
183
187
  expect(r.stderr).toMatch(/retry exhausted/)
184
188
  // State unchanged.
185
189
  const state = JSON.parse(readFileSync(statePath, 'utf8'))
186
- expect(state.retryCount).toBe(1)
190
+ expect(state.retryCount).toBe(SILENT_END_MAX_RETRIES)
187
191
  })
188
192
 
189
193
  it('NO_REPLY in transcript → allow stop, no state file written', () => {
@@ -226,10 +226,11 @@ describe('recordSilentTurnEnd — #1161 exhaustion detection', () => {
226
226
  it('full lifecycle: silent → re-prompt → still silent → exhausted', () => {
227
227
  // 1. Turn ends silent — first record.
228
228
  expect(recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' }).exhausted).toBe(false)
229
- // 2. Stop hook blocks and increments retryCount (simulated).
229
+ // 2. Stop hook blocks and bumps retryCount up to the ceiling
230
+ // (simulated; the hook does retryCount+1 each block until MAX).
230
231
  const path = join(stateDir, 'silent-end-pending.json')
231
232
  const s = readSilentEndState()!
232
- writeFileSync(path, JSON.stringify({ ...s, retryCount: s.retryCount + 1 }))
233
+ writeFileSync(path, JSON.stringify({ ...s, retryCount: SILENT_END_MAX_RETRIES }))
233
234
  // 3. Re-prompted turn ends silent again — recovery exhausted.
234
235
  expect(recordSilentTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:_' }).exhausted).toBe(true)
235
236
  expect(readSilentEndState()).toBeNull()
@@ -346,10 +347,11 @@ describe('recordUndeliveredTurnEnd — #1664 extended trigger', () => {
346
347
  [{ text: 'one sec', disableNotification: true }],
347
348
  'c:exhaust',
348
349
  ).rePromptEngaged).toBe(true)
349
- // Stop hook blocks once and bumps retryCount (simulated).
350
+ // Stop hook blocks until retryCount hits the ceiling (simulated;
351
+ // the hook does retryCount+1 each block until SILENT_END_MAX_RETRIES).
350
352
  const path = join(stateDir, 'silent-end-pending.json')
351
353
  const s = readSilentEndState()!
352
- writeFileSync(path, JSON.stringify({ ...s, retryCount: s.retryCount + 1 }))
354
+ writeFileSync(path, JSON.stringify({ ...s, retryCount: SILENT_END_MAX_RETRIES }))
353
355
  // Re-prompted turn STILL ends with only an interim ack → exhausted.
354
356
  const second = recordUndeliveredTurnEnd({ chatId: 'c', threadId: null, turnKey: 'c:exhaust' })
355
357
  expect(second.exhausted).toBe(true)
@@ -538,13 +540,14 @@ describe('silent-end-interrupt-stop hook — integration (#1775: transcript-scan
538
540
  expect(readSilentEndState()!.retryCount).toBe(1)
539
541
  })
540
542
 
541
- it('allows the stop when retryCount >= MAX_RETRIES (1), even if transcript still shows no reply', () => {
542
- // Retry already spent — gateway will post the user-facing
543
+ it('allows the stop when retryCount >= MAX_RETRIES, even if transcript still shows no reply', () => {
544
+ // Retry budget already spent — gateway will post the user-facing
543
545
  // fallback so the user isn't left silent.
544
546
  const path = join(stateDir, 'silent-end-pending.json')
545
547
  mkdirSync(stateDir, { recursive: true })
546
548
  writeFileSync(path, JSON.stringify({
547
- chatId: 'c', threadId: null, turnKey: 'c:_', retryCount: 1, timestamp: 0,
549
+ chatId: 'c', threadId: null, turnKey: 'c:_',
550
+ retryCount: SILENT_END_MAX_RETRIES, timestamp: 0,
548
551
  }))
549
552
  const transcript = writeTranscript([
550
553
  ENQUEUE,