switchroom 0.19.5 → 0.19.7

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.19.5", COMMIT_SHA = "c5feaef8";
2123
+ var VERSION = "0.19.7", COMMIT_SHA = "2ddec4ae";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -26663,7 +26663,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26663
26663
  import { dirname as dirname4, join as join7 } from "node:path";
26664
26664
 
26665
26665
  // src/build-info.ts
26666
- var VERSION = "0.19.5";
26666
+ var VERSION = "0.19.7";
26667
26667
 
26668
26668
  // src/cli/resolve-version.ts
26669
26669
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.19.5",
4
+ "version": "0.19.7",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -40426,7 +40426,7 @@ function decideSupersede(record, args) {
40426
40426
  if (!sameTurn) {
40427
40427
  return { supersede: false, deleteMessageIds: [], reason: "different-turn" };
40428
40428
  }
40429
- if (args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
40429
+ if (!args.positiveAttribution && args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
40430
40430
  return { supersede: false, deleteMessageIds: [], reason: "new-content", recordText: record.text };
40431
40431
  }
40432
40432
  return {
@@ -40469,6 +40469,7 @@ class FlushedTurnSupersedeRegistry {
40469
40469
  return decideSupersede(rec, {
40470
40470
  liveTurnId: args.liveTurnId,
40471
40471
  replyText: args.replyText,
40472
+ positiveAttribution: args.positiveAttribution,
40472
40473
  now: args.now,
40473
40474
  ttlMs: this.ttlMs
40474
40475
  });
@@ -73954,7 +73955,7 @@ function decideSupersede2(record, args) {
73954
73955
  if (!sameTurn) {
73955
73956
  return { supersede: false, deleteMessageIds: [], reason: "different-turn" };
73956
73957
  }
73957
- if (args.replyText != null && !flushedAnswerMatchesReply2(record.text, args.replyText)) {
73958
+ if (!args.positiveAttribution && args.replyText != null && !flushedAnswerMatchesReply2(record.text, args.replyText)) {
73958
73959
  return { supersede: false, deleteMessageIds: [], reason: "new-content", recordText: record.text };
73959
73960
  }
73960
73961
  return {
@@ -74004,6 +74005,7 @@ class FlushedTurnSupersedeRegistry2 {
74004
74005
  return decideSupersede2(rec, {
74005
74006
  liveTurnId: args.liveTurnId,
74006
74007
  replyText: args.replyText,
74008
+ positiveAttribution: args.positiveAttribution,
74007
74009
  now: args.now,
74008
74010
  ttlMs: this.ttlMs
74009
74011
  });
@@ -74583,6 +74585,7 @@ async function sendReply(deps, req) {
74583
74585
  resolveAnswerThreadWithLog,
74584
74586
  resolveThreadId,
74585
74587
  getLatestInboundMessageId: getLatestInboundMessageId2,
74588
+ getLastSubagentHandbackAt,
74586
74589
  recordOutbound: recordOutbound2,
74587
74590
  emissionAuthorityFor,
74588
74591
  clearActivitySummary,
@@ -74656,9 +74659,15 @@ async function sendReply(deps, req) {
74656
74659
  let supersedeFlushIds = [];
74657
74660
  {
74658
74661
  const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
74659
- const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args);
74662
+ const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args);
74660
74663
  const resolvedTurnId = ownerTurn?.turnId ?? null;
74661
- const decision = flushedTurnSupersede.take(chat_id, replyThreadId, { liveTurnId: resolvedTurnId, replyText: text4, now: Date.now() });
74664
+ const ownerEndedAt = ownerTurn?.endedAt ?? null;
74665
+ const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId;
74666
+ const handbackAt = getLastSubagentHandbackAt(chat_id);
74667
+ const now = Date.now();
74668
+ const handbackCouldOwnReply = handbackAt != null && ownerEndedAt != null && handbackAt > ownerEndedAt && now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS2;
74669
+ const replyIsOwnAnswer = ownerTier === "live" || ownerTier === "latest-ended" && !handbackCouldOwnReply;
74670
+ const decision = flushedTurnSupersede.take(chat_id, gateThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
74662
74671
  if (decision.supersede) {
74663
74672
  process.stderr.write(`telegram gateway: reply: superseding flushed turn message(s) ` + `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}
74664
74673
  `);
@@ -78142,8 +78151,61 @@ function latestEndedAccepted(candidates) {
78142
78151
  return true;
78143
78152
  return age <= ttl;
78144
78153
  }
78154
+ function resolveReplyOwnerTier(candidates) {
78155
+ if (candidates.liveTurnId != null)
78156
+ return "live";
78157
+ if (candidates.originTurnId != null)
78158
+ return "origin";
78159
+ if (candidates.quotedTurnId != null)
78160
+ return "quoted";
78161
+ if (latestEndedAccepted(candidates))
78162
+ return "latest-ended";
78163
+ return "none";
78164
+ }
78145
78165
  function resolveReplyOwnerTurnId(candidates) {
78146
- return candidates.liveTurnId ?? candidates.originTurnId ?? candidates.quotedTurnId ?? (latestEndedAccepted(candidates) ? candidates.latestEndedTurnId : null) ?? null;
78166
+ switch (resolveReplyOwnerTier(candidates)) {
78167
+ case "live":
78168
+ return candidates.liveTurnId;
78169
+ case "origin":
78170
+ return candidates.originTurnId;
78171
+ case "quoted":
78172
+ return candidates.quotedTurnId;
78173
+ case "latest-ended":
78174
+ return candidates.latestEndedTurnId;
78175
+ case "none":
78176
+ return null;
78177
+ }
78178
+ }
78179
+
78180
+ // gateway/subagent-handback-marker.ts
78181
+ var MAIN_THREAD_KEY = "<main>";
78182
+
78183
+ class SubagentHandbackMarker {
78184
+ byChat = new Map;
78185
+ threadKey(threadId) {
78186
+ return threadId == null ? MAIN_THREAD_KEY : String(threadId);
78187
+ }
78188
+ record(chatId, threadId, now) {
78189
+ let inner = this.byChat.get(chatId);
78190
+ if (inner == null) {
78191
+ inner = new Map;
78192
+ this.byChat.set(chatId, inner);
78193
+ }
78194
+ inner.set(this.threadKey(threadId), now);
78195
+ }
78196
+ lastAt(chatId, threadId) {
78197
+ return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
78198
+ }
78199
+ lastAtInChat(chatId) {
78200
+ const inner = this.byChat.get(chatId);
78201
+ if (inner == null || inner.size === 0)
78202
+ return null;
78203
+ let max = -Infinity;
78204
+ for (const ts of inner.values())
78205
+ if (ts > max)
78206
+ max = ts;
78207
+ return max === -Infinity ? null : max;
78208
+ }
78147
78209
  }
78148
78210
 
78149
78211
  // answer-ready-flush.ts
@@ -84622,6 +84684,76 @@ function buildDiffPreviewCard(input) {
84622
84684
  return { text: text4, reply_markup: kb };
84623
84685
  }
84624
84686
 
84687
+ // gateway/subagent-handback-marker.ts
84688
+ var INBOUND_SOURCE_CLASSIFICATION = {
84689
+ subagent_handback: { decoupledCompletion: true },
84690
+ cron: { decoupledCompletion: false },
84691
+ reaction: { decoupledCompletion: false },
84692
+ subagent_progress: { decoupledCompletion: false },
84693
+ resume_interrupted: { decoupledCompletion: false },
84694
+ resume_deferred: { decoupledCompletion: false },
84695
+ resume_watchdog_timeout: { decoupledCompletion: false },
84696
+ vault_grant_approved: { decoupledCompletion: false },
84697
+ vault_grant_denied: { decoupledCompletion: false },
84698
+ vault_grant_timeout: { decoupledCompletion: false },
84699
+ vault_save_completed: { decoupledCompletion: false },
84700
+ vault_save_discarded: { decoupledCompletion: false },
84701
+ vault_save_failed: { decoupledCompletion: false },
84702
+ vault_save_timeout: { decoupledCompletion: false },
84703
+ secret_provided: { decoupledCompletion: false },
84704
+ secret_declined: { decoupledCompletion: false },
84705
+ secret_provide_failed: { decoupledCompletion: false },
84706
+ secret_request_timeout: { decoupledCompletion: false },
84707
+ mental_model_propose_timeout: { decoupledCompletion: false },
84708
+ bridge_dead_restart: { decoupledCompletion: false },
84709
+ obligation_represent: { decoupledCompletion: false },
84710
+ missed_approval_retry: { decoupledCompletion: false },
84711
+ skill_proposal_apply: { decoupledCompletion: false },
84712
+ warmup: { decoupledCompletion: false },
84713
+ mental_model_proposal_applied: { decoupledCompletion: false },
84714
+ mental_model_proposal_denied: { decoupledCompletion: false },
84715
+ mental_model_proposal_failed: { decoupledCompletion: false },
84716
+ webhook: { decoupledCompletion: false },
84717
+ linear: { decoupledCompletion: false }
84718
+ };
84719
+ function stampsHandbackMarker(source) {
84720
+ if (source == null)
84721
+ return false;
84722
+ const known = INBOUND_SOURCE_CLASSIFICATION[source];
84723
+ if (known == null)
84724
+ return true;
84725
+ return known.decoupledCompletion;
84726
+ }
84727
+ var MAIN_THREAD_KEY2 = "<main>";
84728
+
84729
+ class SubagentHandbackMarker2 {
84730
+ byChat = new Map;
84731
+ threadKey(threadId) {
84732
+ return threadId == null ? MAIN_THREAD_KEY2 : String(threadId);
84733
+ }
84734
+ record(chatId, threadId, now) {
84735
+ let inner = this.byChat.get(chatId);
84736
+ if (inner == null) {
84737
+ inner = new Map;
84738
+ this.byChat.set(chatId, inner);
84739
+ }
84740
+ inner.set(this.threadKey(threadId), now);
84741
+ }
84742
+ lastAt(chatId, threadId) {
84743
+ return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
84744
+ }
84745
+ lastAtInChat(chatId) {
84746
+ const inner = this.byChat.get(chatId);
84747
+ if (inner == null || inner.size === 0)
84748
+ return null;
84749
+ let max = -Infinity;
84750
+ for (const ts of inner.values())
84751
+ if (ts > max)
84752
+ max = ts;
84753
+ return max === -Infinity ? null : max;
84754
+ }
84755
+ }
84756
+
84625
84757
  // gateway/pending-inbound-buffer.ts
84626
84758
  var DEFAULT_PENDING_INBOUND_CAP = 32;
84627
84759
  function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
@@ -84736,6 +84868,11 @@ function createPendingInboundBuffer(opts = {}) {
84736
84868
  }
84737
84869
  }
84738
84870
  q.push(msg);
84871
+ if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
84872
+ try {
84873
+ opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts);
84874
+ } catch {}
84875
+ }
84739
84876
  spool?.put(agent, msg);
84740
84877
  log(`pending-inbound-buffer: agent=${agent} buffered source=${msg.meta?.source ?? "-"} ` + `depth_after=${q.length} evicted=${evicted}
84741
84878
  `);
@@ -93414,10 +93551,10 @@ function startGatewayHeartbeat(stateDir, intervalMs = GATEWAY_HEARTBEAT_INTERVAL
93414
93551
  }
93415
93552
 
93416
93553
  // ../src/build-info.ts
93417
- var VERSION = "0.19.5";
93418
- var COMMIT_SHA = "c5feaef8";
93419
- var COMMIT_DATE = "2026-07-20T10:20:34Z";
93420
- var LATEST_PR = 3474;
93554
+ var VERSION = "0.19.7";
93555
+ var COMMIT_SHA = "2ddec4ae";
93556
+ var COMMIT_DATE = "2026-07-20T19:59:43Z";
93557
+ var LATEST_PR = 3478;
93421
93558
  var COMMITS_AHEAD_OF_TAG = 0;
93422
93559
 
93423
93560
  // gateway/boot-version.ts
@@ -96335,6 +96472,8 @@ function cardDrainGate(turn, ea, run4) {
96335
96472
  var RECENT_TURNS_MAX = 32;
96336
96473
  var recentTurnsById = new Map;
96337
96474
  var recentTurnIdBySourceMessageId = new Map;
96475
+ var subagentHandbackMarker = new SubagentHandbackMarker;
96476
+ var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.lastAtInChat(chatId);
96338
96477
  function rememberRecentTurn(turn) {
96339
96478
  recentTurnsById.set(turn.turnId, turn);
96340
96479
  if (turn.sourceMessageId != null) {
@@ -96401,15 +96540,17 @@ function resolveReplyOwnerTurn(liveTurn, chatId, args) {
96401
96540
  byId.set(t.turnId, t);
96402
96541
  }
96403
96542
  const latestEndedAgeMs = latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null;
96404
- const winnerId = resolveReplyOwnerTurnId({
96543
+ const candidates = {
96405
96544
  liveTurnId: liveTurn?.turnId ?? null,
96406
96545
  originTurnId: origin?.turnId ?? null,
96407
96546
  quotedTurnId: quoted?.turnId ?? null,
96408
96547
  latestEndedTurnId: latestEnded?.turnId ?? null,
96409
96548
  latestEndedAgeMs,
96410
96549
  latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS
96411
- });
96412
- return winnerId != null ? byId.get(winnerId) ?? null : null;
96550
+ };
96551
+ const tier = resolveReplyOwnerTier(candidates);
96552
+ const winnerId = resolveReplyOwnerTurnId(candidates);
96553
+ return { turn: winnerId != null ? byId.get(winnerId) ?? null : null, tier };
96413
96554
  }
96414
96555
  function resolveAnswerThreadWithLog(chatId, explicitThreadId, originTurn, originVia, liveTurn, surface) {
96415
96556
  const recovered = LATE_REPLY_TOPIC_RECOVERY_ENABLED && explicitThreadId == null && originTurn == null && liveTurn == null ? findLatestEndedTurnForChat(chatId) : null;
@@ -99121,7 +99262,8 @@ var pendingInboundBuffer = createPendingInboundBuffer({
99121
99262
  evictNoticeByChat.set(key, nowMs3);
99122
99263
  const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
99123
99264
  swallowingApiCall(() => bot.api.sendMessage(chat, "\u23F3 Messages are arriving faster than I can process them. Your messages are saved and will be handled once I finish the current turn \u2014 if any can't be picked up, I'll ask you to resend it.", { ...threadOpts }), { chat_id: chat, verb: "inbound-buffer-eviction" });
99124
- }
99265
+ },
99266
+ onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts)
99125
99267
  });
99126
99268
  function agentHasInFlightBackgroundWork(now) {
99127
99269
  if (countRunningWorkers() > 0)
@@ -100499,6 +100641,7 @@ function gatewaySendReplyDeps() {
100499
100641
  resolveAnswerThreadWithLog,
100500
100642
  resolveThreadId,
100501
100643
  getLatestInboundMessageId,
100644
+ getLastSubagentHandbackAt,
100502
100645
  recordOutbound,
100503
100646
  emissionAuthorityFor,
100504
100647
  clearActivitySummary,
@@ -181,7 +181,13 @@ export function flushedAnswerMatchesReply(flushedText: string, replyText: string
181
181
  */
182
182
  export function decideSupersede(
183
183
  record: FlushedTurnRecord | undefined,
184
- args: { liveTurnId: string | null; replyText?: string | null; now: number; ttlMs?: number },
184
+ args: {
185
+ liveTurnId: string | null
186
+ replyText?: string | null
187
+ positiveAttribution?: boolean
188
+ now: number
189
+ ttlMs?: number
190
+ },
185
191
  ): SupersedeDecision {
186
192
  const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
187
193
  if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
@@ -195,10 +201,29 @@ export function decideSupersede(
195
201
  if (!sameTurn) {
196
202
  return { supersede: false, deleteMessageIds: [], reason: 'different-turn' }
197
203
  }
198
- // #3429 — same turn identity, but genuinely different content: an async
199
- // handback attributed to the flush-delivered ended turn, not the turn's own
200
- // answer landing late. Never edit/delete the flushed message for it.
201
- if (args.replyText != null && !flushedAnswerMatchesReply(record.text, args.replyText)) {
204
+ // #3429 content gate but ONLY when the caller is UNSURE the reply is this
205
+ // turn's own answer. `positiveAttribution === true` means the gateway
206
+ // established the reply IS this turn's OWN late answer either by a positive
207
+ // owner-resolution tier (live atom / `origin_turn_id` echo / quoted message
208
+ // id) OR because no background `subagent_handback` could own it (none enqueued
209
+ // for the chat after the flushed turn ended within the supersede TTL; see
210
+ // outbound-send-path). Then supersede the flushed provisional draft REGARDLESS
211
+ // of text, so a model that RE-WORDED the answer between the narration that
212
+ // flushed and the `reply` tool call still collapses to ONE message (the
213
+ // duplicate-reply regression #3429 introduced on the reworded same-turn path).
214
+ //
215
+ // The content gate remains when `positiveAttribution` is falsey — the
216
+ // ambiguous residual (latest-ended tier AND a handback in flight) where
217
+ // identity alone cannot tell the turn's own late reply from a background
218
+ // sub-agent handback that resolved this flush-delivered ended turn as its
219
+ // owner. There, genuinely different content is a handback and must send FRESH
220
+ // (both messages surface), never edit/delete the flushed answer. Legacy
221
+ // identity-only callers (no `replyText`) keep the pre-#3429 behaviour.
222
+ if (
223
+ !args.positiveAttribution &&
224
+ args.replyText != null &&
225
+ !flushedAnswerMatchesReply(record.text, args.replyText)
226
+ ) {
202
227
  return { supersede: false, deleteMessageIds: [], reason: 'new-content', recordText: record.text }
203
228
  }
204
229
  return {
@@ -326,12 +351,18 @@ export class FlushedTurnSupersedeRegistry {
326
351
  peek(
327
352
  chatId: string,
328
353
  threadId: number | undefined,
329
- args: { liveTurnId: string | null; replyText?: string | null; now: number },
354
+ args: {
355
+ liveTurnId: string | null
356
+ replyText?: string | null
357
+ positiveAttribution?: boolean
358
+ now: number
359
+ },
330
360
  ): SupersedeDecision {
331
361
  const rec = this.entries.get(makeKey(chatId, threadId))?.get(turnKey(args.liveTurnId))
332
362
  return decideSupersede(rec, {
333
363
  liveTurnId: args.liveTurnId,
334
364
  replyText: args.replyText,
365
+ positiveAttribution: args.positiveAttribution,
335
366
  now: args.now,
336
367
  ttlMs: this.ttlMs,
337
368
  })
@@ -345,7 +376,12 @@ export class FlushedTurnSupersedeRegistry {
345
376
  take(
346
377
  chatId: string,
347
378
  threadId: number | undefined,
348
- args: { liveTurnId: string | null; replyText?: string | null; now: number },
379
+ args: {
380
+ liveTurnId: string | null
381
+ replyText?: string | null
382
+ positiveAttribution?: boolean
383
+ now: number
384
+ },
349
385
  ): SupersedeDecision {
350
386
  const lane = makeKey(chatId, threadId)
351
387
  const decision = this.peek(chatId, threadId, args)
@@ -485,8 +485,11 @@ import {
485
485
  } from '../turn-flush-safety.js'
486
486
  import {
487
487
  resolveReplyOwnerTurnId,
488
+ resolveReplyOwnerTier,
489
+ type ReplyOwnerTier,
488
490
  type AnswerDeliveredLatch,
489
491
  } from '../reply-owner-resolve.js'
492
+ import { SubagentHandbackMarker } from './subagent-handback-marker.js'
490
493
  // PR A — deterministic answer-ready quiescence flush (late-delivery fix).
491
494
  import {
492
495
  AnswerReadyFlushController,
@@ -3885,6 +3888,19 @@ const recentTurnsById = new Map<string, CurrentTurn>()
3885
3888
  // real message_id the framework stamped, never a model-asserted thread.
3886
3889
  // Evicted in lock-step with recentTurnsById so it can't outgrow it.
3887
3890
  const recentTurnIdBySourceMessageId = new Map<number, string>()
3891
+
3892
+ // fix/backstop-duplicate-reply — per-chat/thread marker of the most recent
3893
+ // gateway-synthesized `subagent_handback` enqueue (logic in
3894
+ // subagent-handback-marker.ts; extracted per the gateway anti-inflation ratchet).
3895
+ // The record is thread-resolved (retains the originating topic), but the
3896
+ // content-gate READ is CHAT-WIDE (dup-audit MUST-FIX 2): the latest-ended owner
3897
+ // tier is chat-wide, so a thread-specific gate read was steerable by the reply's
3898
+ // own `message_thread_id` → silent edit-over. `lastAtInChat` makes the gate
3899
+ // un-steerable (any in-window handback in the chat keeps the content gate).
3900
+ const subagentHandbackMarker = new SubagentHandbackMarker()
3901
+ const getLastSubagentHandbackAt = (chatId: string): number | null =>
3902
+ subagentHandbackMarker.lastAtInChat(chatId)
3903
+
3888
3904
  function rememberRecentTurn(turn: CurrentTurn): void {
3889
3905
  recentTurnsById.set(turn.turnId, turn)
3890
3906
  if (turn.sourceMessageId != null) {
@@ -4009,7 +4025,7 @@ function resolveReplyOwnerTurn(
4009
4025
  liveTurn: CurrentTurn | null,
4010
4026
  chatId: string,
4011
4027
  args: Record<string, unknown>,
4012
- ): CurrentTurn | null {
4028
+ ): { turn: CurrentTurn | null; tier: ReplyOwnerTier } {
4013
4029
  const origin = findTurnByOriginId(args.origin_turn_id as string | undefined)
4014
4030
  const quoted = findTurnByQuotedMessageId(chatId, args.reply_to)
4015
4031
  const latestEnded = findLatestEndedTurnForChat(chatId)
@@ -4026,15 +4042,23 @@ function resolveReplyOwnerTurn(
4026
4042
  // fabricate one.
4027
4043
  const latestEndedAgeMs =
4028
4044
  latestEnded?.endedAt != null ? Date.now() - latestEnded.endedAt : null
4029
- const winnerId = resolveReplyOwnerTurnId({
4045
+ const candidates = {
4030
4046
  liveTurnId: liveTurn?.turnId ?? null,
4031
4047
  originTurnId: origin?.turnId ?? null,
4032
4048
  quotedTurnId: quoted?.turnId ?? null,
4033
4049
  latestEndedTurnId: latestEnded?.turnId ?? null,
4034
4050
  latestEndedAgeMs,
4035
4051
  latestEndedTtlMs: DEFAULT_SUPERSEDE_TTL_MS,
4036
- })
4037
- return winnerId != null ? (byId.get(winnerId) ?? null) : null
4052
+ }
4053
+ // #3429 the WINNING tier travels with the turn. A positive tier
4054
+ // (live/origin/quoted) means the reply is this turn's own answer and the
4055
+ // supersede fires regardless of text; the ambiguous `latest-ended` fallback
4056
+ // keeps the content gate (it cannot tell a late own-reply from an async
4057
+ // sub-agent handback). Both derive from the SAME candidates, so the id and the
4058
+ // tier can never disagree.
4059
+ const tier = resolveReplyOwnerTier(candidates)
4060
+ const winnerId = resolveReplyOwnerTurnId(candidates)
4061
+ return { turn: winnerId != null ? (byId.get(winnerId) ?? null) : null, tier }
4038
4062
  }
4039
4063
 
4040
4064
  /**
@@ -9962,6 +9986,10 @@ const pendingInboundBuffer = createPendingInboundBuffer({
9962
9986
  { chat_id: chat, verb: 'inbound-buffer-eviction' },
9963
9987
  )
9964
9988
  },
9989
+ // fix/backstop-duplicate-reply MUST-FIX 2 — stamp the handback marker at the
9990
+ // enqueue chokepoint so a boot-replayed handback (not just the live synthesis
9991
+ // push) populates it. Every `subagent_handback` push funnels through here.
9992
+ onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
9965
9993
  })
9966
9994
 
9967
9995
  // PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
@@ -12567,6 +12595,7 @@ function gatewaySendReplyDeps(): SendReplyGatewayDeps {
12567
12595
  resolveAnswerThreadWithLog,
12568
12596
  resolveThreadId,
12569
12597
  getLatestInboundMessageId,
12598
+ getLastSubagentHandbackAt,
12570
12599
  recordOutbound,
12571
12600
  emissionAuthorityFor,
12572
12601
  clearActivitySummary,
@@ -24458,6 +24487,9 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24458
24487
  // The drain only releases at an idle prompt (no active
24459
24488
  // turn), so the handback always lands as a clean fresh
24460
24489
  // turn and never races a turn-in-flight composer (#1556).
24490
+ // The handback marker is stamped inside pendingInboundBuffer.push
24491
+ // (the enqueue chokepoint) so BOTH this live synthesis push and
24492
+ // the boot-replay re-push populate it — see MUST-FIX 2.
24461
24493
  pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', decision.inbound)
24462
24494
  process.stderr.write(
24463
24495
  `telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}\n`,
@@ -56,9 +56,10 @@ import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
56
56
  import {
57
57
  decideSupersedeCorrection,
58
58
  flushedAnswerMatchesReply,
59
+ DEFAULT_SUPERSEDE_TTL_MS,
59
60
  type FlushedTurnSupersedeRegistry,
60
61
  } from '../flushed-turn-supersede.js'
61
- import { decideAnswerLatchSuppression } from '../reply-owner-resolve.js'
62
+ import { decideAnswerLatchSuppression, type ReplyOwnerTier } from '../reply-owner-resolve.js'
62
63
  import { deriveTelegraphTitle } from '../telegraph.js'
63
64
  import {
64
65
  mintVoiceOnDemandToken,
@@ -658,7 +659,7 @@ export interface SendReplyGatewayDeps {
658
659
  assertSendable(f: string): void
659
660
  statusKey(chatId: string, threadId?: number | null): string
660
661
  streamKey(chatId: string, threadId?: number | null): string
661
- resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): CurrentTurn | null
662
+ resolveReplyOwnerTurn(liveTurn: CurrentTurn | null, chatId: string, args: Record<string, unknown>): { turn: CurrentTurn | null; tier: ReplyOwnerTier }
662
663
  findTurnByOriginId(originTurnId: string | null | undefined): CurrentTurn | null
663
664
  findTurnByQuotedMessageId(chatId: string, replyTo: unknown): CurrentTurn | null
664
665
  resolveAnswerThreadWithLog(
@@ -671,6 +672,7 @@ export interface SendReplyGatewayDeps {
671
672
  ): number | undefined
672
673
  resolveThreadId(chatId: string, explicit?: string | number | null): number | undefined
673
674
  getLatestInboundMessageId(chatId: string, threadId: number | null): number | null | undefined
675
+ getLastSubagentHandbackAt(chatId: string): number | null
674
676
  recordOutbound(rec: {
675
677
  chat_id: string
676
678
  thread_id: number | null
@@ -735,7 +737,7 @@ export async function sendReply(
735
737
  statusKey, streamKey,
736
738
  resolveReplyOwnerTurn, findTurnByOriginId, findTurnByQuotedMessageId,
737
739
  resolveAnswerThreadWithLog, resolveThreadId,
738
- getLatestInboundMessageId, recordOutbound,
740
+ getLatestInboundMessageId, getLastSubagentHandbackAt, recordOutbound,
739
741
  emissionAuthorityFor, clearActivitySummary,
740
742
  startTypingLoop, stopTypingLoop, logOutbound,
741
743
  closeObligationOnSubstantiveReply, finalizeStatusReaction,
@@ -874,18 +876,95 @@ export async function sendReply(
874
876
  // double-send). The quoted / latest-ended recoveries are precisely what the
875
877
  // router already did for the same reply, so unifying here makes the two
876
878
  // resolvers agree and the late-reply supersede fires by identity.
877
- const ownerTurn = resolveReplyOwnerTurn(turn, chat_id, args)
879
+ const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args)
878
880
  const resolvedTurnId = ownerTurn?.turnId ?? null
879
- // #3429 — pass the (normalized) reply text so the registry can apply the
880
- // new-content gate: identity match + TTL alone also fits an async handback
881
- // that merely resolved this flush-delivered ENDED turn as its owner via
882
- // the latest-ended tier. Editing the flushed message in place with that
883
- // handback's text does not re-notify client-side (Telegram edits never
884
- // push) the observed silent non-surfacing of msgs 10482/10486.
881
+ // #3429 — pass the (normalized) reply text so the registry CAN apply the
882
+ // new-content gate: identity match + TTL alone also fits a background
883
+ // sub-agent handback that merely resolved this flush-delivered ENDED turn as
884
+ // its owner via the latest-ended tier. Editing/deleting the flushed message
885
+ // for that handback's unrelated text is the #3429 silent client-side drop
886
+ // (msgs 10482/10486). But that gate ALSO declines the turn's OWN reworded
887
+ // late reply (model narrated → flushed → fired `reply` with a paraphrase),
888
+ // which is the dominant real duplicate (agent:marko 2026-07-20: 11/11
889
+ // declines were own-replies, turns #1177/#1182/#1201 double-sent). So we
890
+ // BYPASS the content gate ONLY when the reply is confidently the flushed
891
+ // turn's OWN answer. The primary, deterministic signal is the absence of a
892
+ // background sub-agent handback that could own this reply: the gateway
893
+ // records when it synthesizes a `subagent_handback` inbound per chat, and if
894
+ // NONE was enqueued for this chat AFTER the flushed turn ended within the
895
+ // supersede TTL, the reply is that turn's own answer → supersede regardless
896
+ // of a model rewording (closes the DM default-reply duplicate, where every
897
+ // own-reply resolves via the latest-ended tier).
898
+ //
899
+ // MUST-FIX 1 (silent-data-loss): the owner-resolution `quoted` / `origin`
900
+ // tiers are derived from MODEL-SUPPLIED args (`args.reply_to` /
901
+ // `args.origin_turn_id`), so a reply can STEER itself — pass
902
+ // `reply_to = <another ended turn's source msg>` and it resolves THAT turn via
903
+ // `quoted`. Marker-absence proves "own answer" ONLY for the framework-derived
904
+ // `latest-ended` tier; a model-steered `quoted`/`origin` attribution with no
905
+ // marker is NOT evidence of ownership, so bypassing the content gate there
906
+ // silently edits over a different ended turn's delivered answer (the #3429
907
+ // double-loss, executed by Fable 2026-07-21). The `replyIsOwnAnswer`
908
+ // computation below therefore restricts the bypass to `live` + `latest-ended`;
909
+ // `quoted`/`origin` ALWAYS traverse the content gate. See that comment.
910
+ //
911
+ // The content gate is therefore kept whenever a decoupled completion WAS
912
+ // enqueued in the window (on the latest-ended tier) OR the reply resolved via
913
+ // a model-steerable tier: the late reply might carry foreign content, so it
914
+ // sends fresh (two messages, #3429 preserved). Concurrency note: a case-A own
915
+ // reply coinciding with an unrelated background handback in the same ≤TTL
916
+ // window degrades to two messages — safe (never a silent drop/edit).
917
+ const ownerEndedAt = ownerTurn?.endedAt ?? null
918
+ // MUST-FIX 2 (dup-audit / Fable 2026-07-21) — key BOTH the supersede lane and
919
+ // the marker-gate read on the FRAMEWORK-resolved thread (the owner turn's
920
+ // `sessionThreadId` — the lane the flush recorded on), NOT the raw model arg
921
+ // `args.message_thread_id` (`replyThreadId`). The flush records on
922
+ // `turn.sessionThreadId`; the owner turn resolved here IS that turn, so its
923
+ // thread is where its record and its handback marker live. Reading the gate on
924
+ // the raw arg let a reply carry `message_thread_id=<other topic>` to dodge a
925
+ // handback marker stamped on the real topic → silent edit-over (the regression
926
+ // F2's raw-arg keying introduced). The owner turn's thread is not model-
927
+ // derived, so it cannot be steered. Falls back to the raw arg only when no
928
+ // owner turn resolved (no record to clobber on the collapse path).
929
+ const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId
930
+ // MUST-FIX 2 (dup-audit / Fable) — the content-gate READ is CHAT-WIDE, not
931
+ // lane-specific: `findLatestEndedTurnForChat` resolves owners chat-wide, so a
932
+ // handback in topic A can supersede topic B's ended turn; a thread-keyed gate
933
+ // read (the F2 regression) let a reply dodge that handback by carrying a
934
+ // different `message_thread_id`. Chat-wide makes the gate un-steerable — any
935
+ // in-window handback in the chat keeps the content gate (accepting the F2
936
+ // visible-dup in the overlap window; a self-healing dup beats a silent loss).
937
+ // The supersede `take()` LANE below stays thread-resolved (`gateThreadId` =
938
+ // the owner turn's framework thread, not the raw arg), so a handback's
939
+ // correction only ever touches ITS OWN topic's record.
940
+ const handbackAt = getLastSubagentHandbackAt(chat_id)
941
+ const now = Date.now()
942
+ const handbackCouldOwnReply =
943
+ handbackAt != null &&
944
+ ownerEndedAt != null &&
945
+ handbackAt > ownerEndedAt &&
946
+ now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
947
+ // MUST-FIX 1 (silent-data-loss, PROVEN by Fable 2026-07-21) — restrict the
948
+ // content-gate BYPASS to the tiers whose attribution is NOT model-steerable:
949
+ // - `live` — the framework-owned live `currentTurn` (not model-derived,
950
+ // and `decideSupersede`'s same-turnId check bars it from an
951
+ // ended turn's record). Bypasses even a handback window.
952
+ // - `latest-ended` — the ambiguous DM/late-reply fallback the marko fix
953
+ // actually needs; bypass ONLY when no decoupled completion is
954
+ // in the window (marker-absence ⇒ own answer).
955
+ // The `quoted` / `origin` tiers resolve from MODEL-SUPPLIED args
956
+ // (`args.reply_to` / `args.origin_turn_id`), so a reply can steer ITSELF onto
957
+ // a DIFFERENT ended turn's record — with marker-absence they used to bypass
958
+ // the content gate and silently edit-over that turn's delivered answer (the
959
+ // #3429 double-loss, executed by Fable). Those tiers therefore NEVER bypass:
960
+ // they always go through the content gate, so foreign content sends fresh and
961
+ // only a genuine same-answer reply collapses.
962
+ const replyIsOwnAnswer =
963
+ ownerTier === 'live' || (ownerTier === 'latest-ended' && !handbackCouldOwnReply)
885
964
  const decision = flushedTurnSupersede.take(
886
965
  chat_id,
887
- replyThreadId,
888
- { liveTurnId: resolvedTurnId, replyText: text, now: Date.now() },
966
+ gateThreadId,
967
+ { liveTurnId: resolvedTurnId, replyText: text, positiveAttribution: replyIsOwnAnswer, now },
889
968
  )
890
969
  if (decision.supersede) {
891
970
  process.stderr.write(