switchroom 0.13.18 → 0.13.19

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.
@@ -47331,8 +47331,8 @@ var {
47331
47331
  } = import__.default;
47332
47332
 
47333
47333
  // src/build-info.ts
47334
- var VERSION = "0.13.18";
47335
- var COMMIT_SHA = "847c860e";
47334
+ var VERSION = "0.13.19";
47335
+ var COMMIT_SHA = "de154395";
47336
47336
 
47337
47337
  // src/cli/agent.ts
47338
47338
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.18",
3
+ "version": "0.13.19",
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": {
@@ -37062,6 +37062,9 @@ function decideSilentReplyAnchor(input) {
37062
37062
  if (!input.effectivelySilent) {
37063
37063
  return { kind: "fresh", becomesAnchor: false };
37064
37064
  }
37065
+ if (input.wasOverPingSuppressed === true) {
37066
+ return { kind: "fresh", becomesAnchor: false };
37067
+ }
37065
37068
  if (input.hasFiles || input.hasButtons) {
37066
37069
  return { kind: "fresh", becomesAnchor: false };
37067
37070
  }
@@ -48204,10 +48207,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48204
48207
  }
48205
48208
 
48206
48209
  // ../src/build-info.ts
48207
- var VERSION = "0.13.18";
48208
- var COMMIT_SHA = "847c860e";
48209
- var COMMIT_DATE = "2026-05-23T06:38:45Z";
48210
- var LATEST_PR = 1680;
48210
+ var VERSION = "0.13.19";
48211
+ var COMMIT_SHA = "de154395";
48212
+ var COMMIT_DATE = "2026-05-23T07:08:03Z";
48213
+ var LATEST_PR = 1682;
48211
48214
  var COMMITS_AHEAD_OF_TAG = 0;
48212
48215
 
48213
48216
  // gateway/boot-version.ts
@@ -50668,6 +50671,7 @@ async function executeReply(args) {
50668
50671
  const format = args.format ?? configParseMode;
50669
50672
  const disableLinkPreview = args.disable_web_page_preview != null ? Boolean(args.disable_web_page_preview) : access.disableLinkPreview ?? true;
50670
50673
  let disableNotification = args.disable_notification === true;
50674
+ let wasOverPingSuppressed = false;
50671
50675
  {
50672
50676
  const turn2 = currentTurn;
50673
50677
  if (turn2 != null) {
@@ -50686,6 +50690,7 @@ async function executeReply(args) {
50686
50690
  sinceFirstPingMs: decision.sinceFirstPingMs ?? 0
50687
50691
  });
50688
50692
  disableNotification = true;
50693
+ wasOverPingSuppressed = true;
50689
50694
  } else if (decision.claimSlot) {
50690
50695
  turn2.firstPingAt = now;
50691
50696
  }
@@ -50794,7 +50799,8 @@ ${url}`;
50794
50799
  anchorText: turn2.silentAnchorText,
50795
50800
  newReplyText: effectiveText,
50796
50801
  hasFiles: files.length > 0,
50797
- hasButtons: replyMarkup != null
50802
+ hasButtons: replyMarkup != null,
50803
+ wasOverPingSuppressed
50798
50804
  });
50799
50805
  if (decision.kind === "edit-anchor") {
50800
50806
  const editParams = {
@@ -4252,6 +4252,12 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4252
4252
  // and subsequent pings would be silenced. Acceptable trade-off (a
4253
4253
  // failed first ping is an edge case; the alternative — claim after
4254
4254
  // send — races concurrent reply calls).
4255
+ // Tracks whether the over-ping safety net coerced this reply
4256
+ // from ping→silent. Threaded into the silent-anchor predicate
4257
+ // below: a demoted final-answer reply must NOT merge into the
4258
+ // silent preamble bubble; it lands as a fresh silent bubble so
4259
+ // the user can still find it (see #1674 / silent-anchor follow-up).
4260
+ let wasOverPingSuppressed = false
4255
4261
  {
4256
4262
  const turn = currentTurn
4257
4263
  if (turn != null) {
@@ -4278,6 +4284,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4278
4284
  sinceFirstPingMs: decision.sinceFirstPingMs ?? 0,
4279
4285
  })
4280
4286
  disableNotification = true
4287
+ wasOverPingSuppressed = true
4281
4288
  } else if (decision.claimSlot) {
4282
4289
  turn.firstPingAt = now
4283
4290
  }
@@ -4445,6 +4452,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4445
4452
  newReplyText: effectiveText,
4446
4453
  hasFiles: files.length > 0,
4447
4454
  hasButtons: replyMarkup != null,
4455
+ wasOverPingSuppressed,
4448
4456
  })
4449
4457
  if (decision.kind === 'edit-anchor') {
4450
4458
  const editParams: {
@@ -65,6 +65,15 @@ export interface SilentReplyAnchorDecisionInput {
65
65
  * are too easy to get wrong, and the markup is rare enough
66
66
  * that fresh-send is the safer default. */
67
67
  hasButtons: boolean
68
+ /** True iff this reply was an intended-ping (model requested
69
+ * `disable_notification:false`) that the over-ping safety net
70
+ * (#1674) coerced to silent. Anchor merge MUST bypass when true:
71
+ * semantically the model intended this as a distinct/final
72
+ * delivery, and merging it into the existing silent preamble
73
+ * would bury the content (the user already stopped looking at
74
+ * the anchor bubble because earlier ticks edited it silently).
75
+ * Optional — defaults to false for non-gateway callers. */
76
+ wasOverPingSuppressed?: boolean
68
77
  }
69
78
 
70
79
  /**
@@ -108,6 +117,19 @@ export function decideSilentReplyAnchor(
108
117
  return { kind: 'fresh', becomesAnchor: false }
109
118
  }
110
119
 
120
+ // Over-ping-suppressed replies bypass the anchor. The model
121
+ // intended a ping (almost always: a final/distinct reply); the
122
+ // safety net demoted to silent so the user isn't double-beeped.
123
+ // Merging the demoted reply into the existing silent anchor
124
+ // hides it — the user has already disengaged from the bubble
125
+ // that's been edited silently for the rest of the turn. Land
126
+ // as a fresh silent bubble instead, preserving discoverability.
127
+ // Don't capture as next anchor either: this reply is the
128
+ // *answer*, not more preamble.
129
+ if (input.wasOverPingSuppressed === true) {
130
+ return { kind: 'fresh', becomesAnchor: false }
131
+ }
132
+
111
133
  // Files / buttons bypass the anchor — edit-text can't merge
112
134
  // media, and keyboards across edits are a foot-gun.
113
135
  if (input.hasFiles || input.hasButtons) {
@@ -175,4 +175,73 @@ describe('decideSilentReplyAnchor — silent replies edit a single growing ancho
175
175
  expect(d.mergedText.length).toBe(TELEGRAM_MSG_CAP)
176
176
  }
177
177
  })
178
+
179
+ // Follow-up to #1679 — when the over-ping safety net coerces a
180
+ // model-intended ping to silent, the demoted reply must NOT be
181
+ // merged into the existing silent anchor. The anchor has been
182
+ // edited silently for the whole turn; the user has long since
183
+ // disengaged. Merging the (semantically final) demoted reply
184
+ // there would hide the answer entirely.
185
+ describe('over-ping-suppressed messages bypass anchor merge', () => {
186
+ it('demoted reply with an active anchor lands as a fresh silent (not edit, not next-anchor)', () => {
187
+ const d = decideSilentReplyAnchor({
188
+ effectivelySilent: true,
189
+ anchorMessageId: 12345,
190
+ anchorText: 'on it — gathering facts',
191
+ newReplyText: 'Delivered all three steps with a wrap-up summary.',
192
+ hasFiles: false,
193
+ hasButtons: false,
194
+ wasOverPingSuppressed: true,
195
+ })
196
+ expect(d).toEqual({ kind: 'fresh', becomesAnchor: false })
197
+ })
198
+
199
+ it('demoted reply with no anchor yet also fresh-sends without capturing the anchor', () => {
200
+ // The model fired a stray ping before any silent ack; the
201
+ // safety-net demoted that ping. A demoted message is never
202
+ // anchor material — it's an answer, not preamble.
203
+ const d = decideSilentReplyAnchor({
204
+ effectivelySilent: true,
205
+ anchorMessageId: null,
206
+ anchorText: '',
207
+ newReplyText: 'Delivered all three steps with a wrap-up summary.',
208
+ hasFiles: false,
209
+ hasButtons: false,
210
+ wasOverPingSuppressed: true,
211
+ })
212
+ expect(d).toEqual({ kind: 'fresh', becomesAnchor: false })
213
+ })
214
+
215
+ it('genuinely silent reply (not over-ping-suppressed) still merges normally', () => {
216
+ // Regression guard: the new bypass must not over-fire on
217
+ // legitimate beat-3 silent ticks.
218
+ const d = decideSilentReplyAnchor({
219
+ effectivelySilent: true,
220
+ anchorMessageId: 12345,
221
+ anchorText: 'on it — gathering facts',
222
+ newReplyText: 'Step 1: hostname is example-host',
223
+ hasFiles: false,
224
+ hasButtons: false,
225
+ wasOverPingSuppressed: false,
226
+ })
227
+ expect(d).toEqual({
228
+ kind: 'edit-anchor',
229
+ messageId: 12345,
230
+ mergedText:
231
+ 'on it — gathering facts\n\nStep 1: hostname is example-host',
232
+ })
233
+ })
234
+
235
+ it('omitting wasOverPingSuppressed defaults to false (backward compat)', () => {
236
+ const d = decideSilentReplyAnchor({
237
+ effectivelySilent: true,
238
+ anchorMessageId: 12345,
239
+ anchorText: 'on it',
240
+ newReplyText: 'next thought',
241
+ hasFiles: false,
242
+ hasButtons: false,
243
+ })
244
+ expect(d.kind).toBe('edit-anchor')
245
+ })
246
+ })
178
247
  })