switchroom 0.19.6 → 0.19.8

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.6", COMMIT_SHA = "ae57dd83";
2123
+ var VERSION = "0.19.8", COMMIT_SHA = "c356d3ba";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -23861,7 +23861,8 @@ function getBuiltinDefaultSkillEntries() {
23861
23861
  "switchroom-health",
23862
23862
  "switchroom-runtime",
23863
23863
  "mental-model-curator",
23864
- "dev-protocol"
23864
+ "dev-protocol",
23865
+ "telegram-formatting"
23865
23866
  ];
23866
23867
  return [
23867
23868
  ...anthropic.map((key) => ({ key, optOutKey: key, source: "anthropic" })),
@@ -29440,6 +29441,11 @@ right answer at all. Structure exists for the reader, not the writer: a two-item
29440
29441
  bullet list is worse than a sentence, a heading on a three-line reply is noise. When
29441
29442
  in doubt, shorter and plainer wins.
29442
29443
 
29444
+ Full palette when a rich or long message earns it \u2014 expandable blockquotes, spoilers,
29445
+ highlight, code-fence language hints, tables, escaping and chunking rules: load the
29446
+ \`telegram-formatting\` skill. Reach for it only when you're actually composing that
29447
+ message, never for everyday replies.
29448
+
29443
29449
  Every turn that answers a user message ends with a user-visible \`reply\`
29444
29450
  \u2014 Telegram is all the user sees; your terminal output
29445
29451
  never reaches them.`, TELEGRAM_ENV_PLACEHOLDER = `# Set your bot token: TELEGRAM_BOT_TOKEN=your-token-here
@@ -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.6";
26666
+ var VERSION = "0.19.8";
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.6",
4
+ "version": "0.19.8",
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": {
@@ -72,11 +72,11 @@ The plugin's auto-retain (Stop hook) fires every turn, but in chunked mode each
72
72
 
73
73
  ### When to synthesize — concrete triggers
74
74
 
75
- Auto-recall and auto-retain keep the bank fed, but they never *synthesize* — that's on you, and it only happens if you act on these triggers. Each is paired with a deterministic backstop so nothing depends on you remembering:
75
+ Auto-recall and auto-retain feed the bank but never *synthesize* — that's on you, only if you act on these triggers. Each has a backstop:
76
76
 
77
- - **Reflect instead of hand-assembling.** When a question needs an answer *across many past memories* — "what did we decide about X", "summarize where Y stands", "have I seen this before" — call `mcp__hindsight__reflect` rather than firing several `recall`s and stitching the fragments yourself. Rule of thumb: if you're about to make 2+ manual `recall` calls to reconstruct one answer, that's a `reflect`. (Backstop: auto-recall already injects the top hits every turn — reflect is the escalation when those aren't enough, not a replacement.)
78
- - **Propose a model when you keep re-deriving.** When you notice you've rebuilt the *same standing answer* two or three sessions running — the recurring state of your specialty a user keeps asking around — stop re-deriving and propose a mental model via `mcp__switchroom-telegram__mental_model_propose(name, source_query)` (or run the `mental-model-curator` skill to survey the bank and propose the earned few). One organic proposal beats ten reflexive recalls. Don't propose for a one-off fact — that's a `retain`; don't propose identity/"who is the user" — profile banks own that.
79
- - **Merge or retire directives when they pile up.** Directives are capped at `MAX_DIRECTIVES=15` active per bank — past that, the lowest-priority ones are silently truncated from the `<active_directives>` recall block and never reach you. When directives start overlapping or reading stale, run the `mental-model-curator` skill's directive merge/retire pass (it proposes; deletes stay operator-approved). (Backstop: `switchroom doctor` WARNs at >12 and FAILs at >15 active directives, so the pile-up surfaces on the issues card even if you don't notice it in-session.)
77
+ - **Reflect instead of hand-assembling.** About to fire 2+ manual `recall`s for one answer ("summarize where Y stands")? Call `mcp__hindsight__reflect` instead. (Backstop: auto-recall injects the top hits every turn — reflect is the escalation.)
78
+ - **Propose a model when you keep re-deriving.** Rebuilt the *same standing answer* across sessions? Propose a mental model via `mcp__switchroom-telegram__mental_model_propose(name, source_query)` (or run the `mental-model-curator` skill). Not for a one-off fact (`retain`) or identity (profile banks own that).
79
+ - **Merge or retire directives when they pile up.** Directives cap at `MAX_DIRECTIVES=15` active per bank — past that the lowest-priority ones drop silently from recall. When they overlap or read stale, run the `mental-model-curator` merge/retire pass (deletes stay operator-approved). (Backstop: `switchroom doctor` WARNs at >12, FAILs at >15.)
80
80
 
81
81
  ## Sub-Agent Delegation
82
82
 
@@ -0,0 +1,147 @@
1
+ ---
2
+ name: telegram-formatting
3
+ description: >
4
+ Use when composing a rich or long Telegram reply and you want the full
5
+ formatting palette with exact syntax — expandable blockquotes, spoilers,
6
+ highlight, code-fence language hints, GFM tables, nested lists — plus the
7
+ escaping rules and the framework's send-time chunking/normalizer behaviour.
8
+ Load it when a message genuinely needs structure, NOT for everyday short
9
+ replies (plain prose already wins there). Teaches judgment first: which
10
+ construct helps the reader vs when plain text is better. Do NOT use for
11
+ deciding whether to reply, or for non-Telegram output.
12
+ ---
13
+
14
+ # Telegram formatting — the full palette
15
+
16
+ Every outbound Switchroom message renders as raw GFM markdown over Telegram
17
+ Bot API 10.1 rich messages (`telegram-plugin/rich-send.ts` `richMessage(md)` →
18
+ `{ markdown }` → `sendRichMessage` / `editMessageText({ markdown })`). No HTML,
19
+ no `parse_mode`. This skill is the depth reference behind the boot-injected
20
+ floor card: the full construct vocabulary, correct syntax, escaping, and the
21
+ send-time behaviour you can rely on.
22
+
23
+ ## Judgment first — reach for structure only when it helps the reader
24
+
25
+ The floor card's stance is the law here too: **structure exists for the reader,
26
+ not the writer.** Loading this skill does not mean "use everything below." Match
27
+ the construct to the message.
28
+
29
+ - **Short answers (a line or two): plain prose, no formatting.** "on it,
30
+ pulling the logs now" is already perfect. No bold, no bullets, no headings.
31
+ Most replies live here — don't dress them up.
32
+ - **Default: light structure.** Bold ONLY the one key fact or answer, never
33
+ more. A list only for 3+ genuinely parallel items the reader will scan or
34
+ compare; two items or a flowing thought stay prose. `code spans` for
35
+ identifiers (filenames, commands, config keys, error codes) — tap-to-copy.
36
+ - **Long / multi-section answers may add the rich constructs below** — tables,
37
+ headings, blockquotes, expandable blocks, fences — but only when they cut the
38
+ reader's effort. If the structure doesn't reduce scanning effort, drop it. A
39
+ two-item bullet list is worse than a sentence; a heading on a three-line reply
40
+ is noise. When in doubt, shorter and plainer wins.
41
+
42
+ Over-bolded messages (most of the text bold, whole paragraphs/lists bolded) get
43
+ their bold stripped at send time — so bold sparingly and deliberately.
44
+
45
+ ## Full rich vocabulary
46
+
47
+ ### Inline spans
48
+
49
+ | Effect | Markdown | When / notes |
50
+ | --- | --- | --- |
51
+ | Bold | `**text**` | The one key fact or answer, not decoration. |
52
+ | Italic | `*text*` or `_text_` | Light emphasis, labels, asides. |
53
+ | Strikethrough | `~~text~~` | Retractions, "was X now Y". |
54
+ | Spoiler | `\|\|text\|\|` | Only for an opt-in surprise or a reveal the reader chose to wait for (a punchline they want suspended) — NEVER to hide an answer someone is asking for or anxious about; when in doubt, show it plainly. Surfaces as a `spoiler` entity on the wire (live-verified 2026-07). |
55
+ | Highlight / marked | `==text==` | Surfaces as a `marked` entity on the wire (live-verified 2026-07). `=` is an `escapeMarkdown` special, so dynamic text won't trigger it by accident. |
56
+ | Inline code | `` `text` `` | Identifiers, tap-to-copy. Content is literal — no escaping inside. |
57
+ | Link | `[label](https://…)` | Standard GFM link. |
58
+
59
+ **Do NOT rely on these — they don't render as intended:**
60
+
61
+ - **Underline** — there is NO underline token on this path. `__text__` renders
62
+ as **bold** (Telegram's rich-message markdown parser reads a `__…__` run
63
+ identically to `**…**`, live-verified against the Bot API 2026-07). Use `**`
64
+ for bold and don't reach for underline.
65
+ - **Subscript** `~text~` (single tilde) and **superscript** `^text^` fall back
66
+ to literal text in rich messages — avoid (write "squared", not `x^2^`).
67
+ - **Custom emoji** (premium custom-emoji entity) renders as a normal emoji for
68
+ non-premium viewers — don't rely on it to carry meaning.
69
+
70
+ (Inline math `$…$`, HTML `<details>`/collapsible, and footnotes `[^1]` are NOT
71
+ supported on this path — do not emit them; they degrade to literal or neutralised
72
+ text.)
73
+
74
+ ### Block types
75
+
76
+ - **Code fence** — ` ```lang ` … ` ``` `. Multi-line literal output (diffs,
77
+ logs, JSON, command blocks). The language hint (`diff`, `json`, `bash`, …)
78
+ sharpens syntax rendering — use it. Content inside is verbatim, never escape
79
+ it; the only hazard is an embedded ` ``` ` closing the block early, which the
80
+ framework defuses (`preBlock` in `shared/bot-runtime.ts`).
81
+ - **Preformatted block** — a code fence with NO language, for fixed-width
82
+ non-code (ASCII tables, aligned columns).
83
+ - **Bulleted list** — `- item` (also `*` / `+`). 3+ parallel items only.
84
+ - **Numbered list** — `1. item`. Ordered steps or ranked items.
85
+ - **Nested lists** — indent sub-items; tight (no blank lines) vs loose (blank
86
+ lines between items) both render. 3-level nesting is live-verified.
87
+ - **Task list** — `- [ ] todo` / `- [x] done`.
88
+ - **Table** — GFM pipe table (`| col | col |` + `| --- | --- |` separator),
89
+ optional per-column alignment (`:---`, `:---:`, `---:`). 2-D data ONLY (rows ×
90
+ columns) — not a substitute for prose. Chunk-safe: `splitMarkdownChunks` never
91
+ bisects a row.
92
+ - **Blockquote** — `> quoted`. Quoted text or an indented continuation; the
93
+ right way to indent, because Telegram drops leading whitespace.
94
+ - **Expandable blockquote** — `**> …` (Bot API 10.1). A long quote/aside the
95
+ reader can collapse and expand. The flagship rich construct — use it for a
96
+ long quotation, a stack trace, or a detailed aside you don't want dominating
97
+ the message. First line carries the `**> ` marker; continuation lines use `> `.
98
+ - **Section heading** — `#` … `######`. Only in a genuinely long, multi-section
99
+ answer. Never on a short reply.
100
+ - **Divider** — `---` (thematic break). Heavy horizontal rule between genuinely
101
+ separate sections. Use sparingly.
102
+ - **Collage / album** — multiple images grouped in one message (media group).
103
+ Send via the attachment path, not markdown.
104
+
105
+ ## Escaping rules
106
+
107
+ Dynamic content (filenames, ids, arbitrary user text) interpolated into a
108
+ hand-built markdown card MUST be escaped so it renders LITERALLY instead of
109
+ being parsed as formatting. Use `escapeMarkdown(value)` from
110
+ `telegram-plugin/format.ts`.
111
+
112
+ `escapeMarkdown` escapes exactly the characters that trigger INLINE formatting:
113
+ backslash, `` ` ``, `*`, `_`, `~`, `=`, `[`, `]`, `|` — the set `` \`*_~=[]| ``.
114
+ The backslash is escaped first so it never double-escapes a following special.
115
+ It deliberately does **not** escape `.` `-` `+` `#` `(` `)` `{` `}` `!` `>`:
116
+ those are only meaningful at line-start or in link/structure context, and
117
+ escaping them mid-word would litter filenames (`foo.ts`), versions (`v1.2-rc`),
118
+ and URLs with visible backslashes.
119
+
120
+ - **Bold/italic a dynamic value:** `` `**${escapeMarkdown(value)}**` ``.
121
+ - **Code-span a dynamic value:** `` `\`${value}\`` `` — code spans need NO
122
+ escaping (backtick content is already literal). This is the preferred, safest
123
+ way to render any identifier.
124
+
125
+ ## Send-time behaviour you can rely on
126
+
127
+ - **Chunking.** Hard cap is `RICH_MESSAGE_MAX_CHARS = 32768` (32768 accepted,
128
+ 32769 rejected — the single constant, never re-derive it). A longer body is
129
+ split by `splitMarkdownChunks(text, 32768)` in `format.ts`: it cuts at the
130
+ largest safe boundary (blank line → newline → space), **never bisects a fenced
131
+ code block** (`backOffOpenFence`) and **never bisects a table row**
132
+ (`backOffTableRow`). A single indivisible region larger than the cap is
133
+ emitted whole and re-split / hard-sliced at send time rather than hanging.
134
+ Long before 32768, ask whether a wall of text is the right answer at all.
135
+ - **Typography normalizer (deterministic, every message).** Block spacing (one
136
+ blank line between distinct blocks), em/en dashes, and `•` bullet markers are
137
+ rewritten at send time. A LONE `\n` between two prose paragraphs is promoted
138
+ to a real visual break; runs of 3+ newlines collapse to `\n\n`; lists, tables,
139
+ code, and existing `\n\n` gaps are left exactly as written. Don't hand-tune
140
+ spacing or fight the normalizer — write the content, the gateway makes the
141
+ typography consistent.
142
+
143
+ ## The one rule that outranks everything here
144
+
145
+ You loaded this skill to format a rich message well — but the best formatting is
146
+ still the least that serves the reader. Use the palette to make a genuinely
147
+ complex answer scannable, never to decorate a simple one.
@@ -41612,6 +41612,7 @@ function shouldEmitShadowTrace(eventKind, effectCount, globalKind, verbose = gwT
41612
41612
  }
41613
41613
 
41614
41614
  // shared/bot-runtime.ts
41615
+ init_rich_send();
41615
41616
  var tgPostTagStore = new AsyncLocalStorage;
41616
41617
  function escapeHtmlForTg(text) {
41617
41618
  return text.replace(/([\\`*_~=\[\]|])/g, "\\$1");
@@ -62511,6 +62512,7 @@ import { createHash as createHash2 } from "crypto";
62511
62512
  import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
62512
62513
  init_flood_circuit_breaker();
62513
62514
  init_format();
62515
+ init_rich_send();
62514
62516
  var tgPostTagStore2 = new AsyncLocalStorage2;
62515
62517
  function _getTgPostTags() {
62516
62518
  return tgPostTagStore2.getStore();
@@ -62555,6 +62557,21 @@ function installTgPostLogger(bot) {
62555
62557
  }
62556
62558
  });
62557
62559
  }
62560
+ function installRichMarkdownGuard(bot) {
62561
+ bot.api.config.use(async (prev, method, payload, signal) => {
62562
+ if ((method === "sendRichMessage" || method === "editMessageText") && payload != null) {
62563
+ const p = payload;
62564
+ const rich = p.rich_message;
62565
+ if (rich != null && typeof rich.markdown === "string") {
62566
+ const guarded = guardAccidentalFormatting(rich.markdown);
62567
+ if (guarded !== rich.markdown) {
62568
+ p.rich_message = { ...rich, markdown: guarded };
62569
+ }
62570
+ }
62571
+ }
62572
+ return prev(method, payload, signal);
62573
+ });
62574
+ }
62558
62575
 
62559
62576
  // flood-circuit-breaker.ts
62560
62577
  import {
@@ -74662,11 +74679,12 @@ async function sendReply(deps, req) {
74662
74679
  const { turn: ownerTurn, tier: ownerTier } = resolveReplyOwnerTurn(turn, chat_id, args);
74663
74680
  const resolvedTurnId = ownerTurn?.turnId ?? null;
74664
74681
  const ownerEndedAt = ownerTurn?.endedAt ?? null;
74682
+ const gateThreadId = ownerTurn?.sessionThreadId ?? replyThreadId;
74665
74683
  const handbackAt = getLastSubagentHandbackAt(chat_id);
74666
74684
  const now = Date.now();
74667
74685
  const handbackCouldOwnReply = handbackAt != null && ownerEndedAt != null && handbackAt > ownerEndedAt && now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS2;
74668
- const replyIsOwnAnswer = ownerTier === "live" || !handbackCouldOwnReply;
74669
- const decision = flushedTurnSupersede.take(chat_id, replyThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
74686
+ const replyIsOwnAnswer = ownerTier === "live" || ownerTier === "latest-ended" && !handbackCouldOwnReply;
74687
+ const decision = flushedTurnSupersede.take(chat_id, gateThreadId, { liveTurnId: resolvedTurnId, replyText: text4, positiveAttribution: replyIsOwnAnswer, now });
74670
74688
  if (decision.supersede) {
74671
74689
  process.stderr.write(`telegram gateway: reply: superseding flushed turn message(s) ` + `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}
74672
74690
  `);
@@ -78177,13 +78195,33 @@ function resolveReplyOwnerTurnId(candidates) {
78177
78195
  }
78178
78196
 
78179
78197
  // gateway/subagent-handback-marker.ts
78198
+ var MAIN_THREAD_KEY = "<main>";
78199
+
78180
78200
  class SubagentHandbackMarker {
78181
- lastAtByChat = new Map;
78182
- record(chatId, now) {
78183
- this.lastAtByChat.set(chatId, now);
78201
+ byChat = new Map;
78202
+ threadKey(threadId) {
78203
+ return threadId == null ? MAIN_THREAD_KEY : String(threadId);
78204
+ }
78205
+ record(chatId, threadId, now) {
78206
+ let inner = this.byChat.get(chatId);
78207
+ if (inner == null) {
78208
+ inner = new Map;
78209
+ this.byChat.set(chatId, inner);
78210
+ }
78211
+ inner.set(this.threadKey(threadId), now);
78184
78212
  }
78185
- lastAt(chatId) {
78186
- return this.lastAtByChat.get(chatId) ?? null;
78213
+ lastAt(chatId, threadId) {
78214
+ return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
78215
+ }
78216
+ lastAtInChat(chatId) {
78217
+ const inner = this.byChat.get(chatId);
78218
+ if (inner == null || inner.size === 0)
78219
+ return null;
78220
+ let max = -Infinity;
78221
+ for (const ts of inner.values())
78222
+ if (ts > max)
78223
+ max = ts;
78224
+ return max === -Infinity ? null : max;
78187
78225
  }
78188
78226
  }
78189
78227
 
@@ -84663,6 +84701,76 @@ function buildDiffPreviewCard(input) {
84663
84701
  return { text: text4, reply_markup: kb };
84664
84702
  }
84665
84703
 
84704
+ // gateway/subagent-handback-marker.ts
84705
+ var INBOUND_SOURCE_CLASSIFICATION = {
84706
+ subagent_handback: { decoupledCompletion: true },
84707
+ cron: { decoupledCompletion: false },
84708
+ reaction: { decoupledCompletion: false },
84709
+ subagent_progress: { decoupledCompletion: false },
84710
+ resume_interrupted: { decoupledCompletion: false },
84711
+ resume_deferred: { decoupledCompletion: false },
84712
+ resume_watchdog_timeout: { decoupledCompletion: false },
84713
+ vault_grant_approved: { decoupledCompletion: false },
84714
+ vault_grant_denied: { decoupledCompletion: false },
84715
+ vault_grant_timeout: { decoupledCompletion: false },
84716
+ vault_save_completed: { decoupledCompletion: false },
84717
+ vault_save_discarded: { decoupledCompletion: false },
84718
+ vault_save_failed: { decoupledCompletion: false },
84719
+ vault_save_timeout: { decoupledCompletion: false },
84720
+ secret_provided: { decoupledCompletion: false },
84721
+ secret_declined: { decoupledCompletion: false },
84722
+ secret_provide_failed: { decoupledCompletion: false },
84723
+ secret_request_timeout: { decoupledCompletion: false },
84724
+ mental_model_propose_timeout: { decoupledCompletion: false },
84725
+ bridge_dead_restart: { decoupledCompletion: false },
84726
+ obligation_represent: { decoupledCompletion: false },
84727
+ missed_approval_retry: { decoupledCompletion: false },
84728
+ skill_proposal_apply: { decoupledCompletion: false },
84729
+ warmup: { decoupledCompletion: false },
84730
+ mental_model_proposal_applied: { decoupledCompletion: false },
84731
+ mental_model_proposal_denied: { decoupledCompletion: false },
84732
+ mental_model_proposal_failed: { decoupledCompletion: false },
84733
+ webhook: { decoupledCompletion: false },
84734
+ linear: { decoupledCompletion: false }
84735
+ };
84736
+ function stampsHandbackMarker(source) {
84737
+ if (source == null)
84738
+ return false;
84739
+ const known = INBOUND_SOURCE_CLASSIFICATION[source];
84740
+ if (known == null)
84741
+ return true;
84742
+ return known.decoupledCompletion;
84743
+ }
84744
+ var MAIN_THREAD_KEY2 = "<main>";
84745
+
84746
+ class SubagentHandbackMarker2 {
84747
+ byChat = new Map;
84748
+ threadKey(threadId) {
84749
+ return threadId == null ? MAIN_THREAD_KEY2 : String(threadId);
84750
+ }
84751
+ record(chatId, threadId, now) {
84752
+ let inner = this.byChat.get(chatId);
84753
+ if (inner == null) {
84754
+ inner = new Map;
84755
+ this.byChat.set(chatId, inner);
84756
+ }
84757
+ inner.set(this.threadKey(threadId), now);
84758
+ }
84759
+ lastAt(chatId, threadId) {
84760
+ return this.byChat.get(chatId)?.get(this.threadKey(threadId)) ?? null;
84761
+ }
84762
+ lastAtInChat(chatId) {
84763
+ const inner = this.byChat.get(chatId);
84764
+ if (inner == null || inner.size === 0)
84765
+ return null;
84766
+ let max = -Infinity;
84767
+ for (const ts of inner.values())
84768
+ if (ts > max)
84769
+ max = ts;
84770
+ return max === -Infinity ? null : max;
84771
+ }
84772
+ }
84773
+
84666
84774
  // gateway/pending-inbound-buffer.ts
84667
84775
  var DEFAULT_PENDING_INBOUND_CAP = 32;
84668
84776
  function redeliverBufferedInbound(buffer, agent, send, spool, onDelivered) {
@@ -84777,9 +84885,9 @@ function createPendingInboundBuffer(opts = {}) {
84777
84885
  }
84778
84886
  }
84779
84887
  q.push(msg);
84780
- if (msg.meta?.source === "subagent_handback" && opts.onHandbackEnqueue != null) {
84888
+ if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
84781
84889
  try {
84782
- opts.onHandbackEnqueue(msg.chatId, msg.ts);
84890
+ opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts);
84783
84891
  } catch {}
84784
84892
  }
84785
84893
  spool?.put(agent, msg);
@@ -93460,10 +93568,10 @@ function startGatewayHeartbeat(stateDir, intervalMs = GATEWAY_HEARTBEAT_INTERVAL
93460
93568
  }
93461
93569
 
93462
93570
  // ../src/build-info.ts
93463
- var VERSION = "0.19.6";
93464
- var COMMIT_SHA = "ae57dd83";
93465
- var COMMIT_DATE = "2026-07-20T16:11:50Z";
93466
- var LATEST_PR = 3476;
93571
+ var VERSION = "0.19.8";
93572
+ var COMMIT_SHA = "c356d3ba";
93573
+ var COMMIT_DATE = "2026-07-20T23:16:37Z";
93574
+ var LATEST_PR = 3483;
93467
93575
  var COMMITS_AHEAD_OF_TAG = 0;
93468
93576
 
93469
93577
  // gateway/boot-version.ts
@@ -96382,7 +96490,7 @@ var RECENT_TURNS_MAX = 32;
96382
96490
  var recentTurnsById = new Map;
96383
96491
  var recentTurnIdBySourceMessageId = new Map;
96384
96492
  var subagentHandbackMarker = new SubagentHandbackMarker;
96385
- var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.lastAt(chatId);
96493
+ var getLastSubagentHandbackAt = (chatId) => subagentHandbackMarker.lastAtInChat(chatId);
96386
96494
  function rememberRecentTurn(turn) {
96387
96495
  recentTurnsById.set(turn.turnId, turn);
96388
96496
  if (turn.sourceMessageId != null) {
@@ -99172,7 +99280,7 @@ var pendingInboundBuffer = createPendingInboundBuffer({
99172
99280
  const threadOpts = evThread != null ? { message_thread_id: evThread } : {};
99173
99281
  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" });
99174
99282
  },
99175
- onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts)
99283
+ onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts)
99176
99284
  });
99177
99285
  function agentHasInFlightBackgroundWork(now) {
99178
99286
  if (countRunningWorkers() > 0)
@@ -106335,6 +106443,7 @@ async function initGatewayBot() {
106335
106443
  }
106336
106444
  bot = new import_grammy15.Bot(TOKEN);
106337
106445
  installTgPostLogger(bot);
106446
+ installRichMarkdownGuard(bot);
106338
106447
  installUpdateTap(bot, (line) => process.stderr.write(line));
106339
106448
  bot.api.config.use(async (prev, method, payload, signal) => {
106340
106449
  try {
@@ -295,7 +295,7 @@ import {
295
295
  } from '../retry-api-call.js'
296
296
  import { createSendGate, sendGateConfigFromEnv, isSendGateShed } from '../send-gate.js'
297
297
  import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
298
- import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
298
+ import { installTgPostLogger, installRichMarkdownGuard, withTgPostTags } from '../shared/bot-runtime.js'
299
299
  import {
300
300
  floodStatePath,
301
301
  floodWindowsPath,
@@ -3889,12 +3889,17 @@ const recentTurnsById = new Map<string, CurrentTurn>()
3889
3889
  // Evicted in lock-step with recentTurnsById so it can't outgrow it.
3890
3890
  const recentTurnIdBySourceMessageId = new Map<number, string>()
3891
3891
 
3892
- // fix/backstop-duplicate-reply — per-chat marker of the most recent
3892
+ // fix/backstop-duplicate-reply — per-chat/thread marker of the most recent
3893
3893
  // gateway-synthesized `subagent_handback` enqueue (logic in
3894
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).
3895
3900
  const subagentHandbackMarker = new SubagentHandbackMarker()
3896
3901
  const getLastSubagentHandbackAt = (chatId: string): number | null =>
3897
- subagentHandbackMarker.lastAt(chatId)
3902
+ subagentHandbackMarker.lastAtInChat(chatId)
3898
3903
 
3899
3904
  function rememberRecentTurn(turn: CurrentTurn): void {
3900
3905
  recentTurnsById.set(turn.turnId, turn)
@@ -9984,7 +9989,7 @@ const pendingInboundBuffer = createPendingInboundBuffer({
9984
9989
  // fix/backstop-duplicate-reply MUST-FIX 2 — stamp the handback marker at the
9985
9990
  // enqueue chokepoint so a boot-replayed handback (not just the live synthesis
9986
9991
  // push) populates it. Every `subagent_handback` push funnels through here.
9987
- onHandbackEnqueue: (chatId, ts) => subagentHandbackMarker.record(chatId, ts),
9992
+ onHandbackEnqueue: (chatId, threadId, ts) => subagentHandbackMarker.record(chatId, threadId, ts),
9988
9993
  })
9989
9994
 
9990
9995
  // PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
@@ -22965,7 +22970,7 @@ async function initGatewayBot(): Promise<void> {
22965
22970
  }
22966
22971
 
22967
22972
  bot = new Bot(TOKEN)
22968
- installTgPostLogger(bot)
22973
+ installTgPostLogger(bot); installRichMarkdownGuard(bot) // #3252/#3463: universal fmt guard installed after logger (composes outermost); see installRichMarkdownGuard docblock
22969
22974
 
22970
22975
  // Diagnostic update tap (#3300): one compact line per received update, logged
22971
22976
  // BEFORE any specific handler runs, so a routing-layer drop is diagnosable
@@ -898,22 +898,45 @@ export async function sendReply(
898
898
  //
899
899
  // MUST-FIX 1 (silent-data-loss): the owner-resolution `quoted` / `origin`
900
900
  // tiers are derived from MODEL-SUPPLIED args (`args.reply_to` /
901
- // `args.origin_turn_id`), so a background handback turn can STEER them
902
- // pass `reply_to = <the user's original msg id>` and it resolves the PRIOR
903
- // flushed turn via `quoted`, which used to force the bypass and silently
904
- // edit-over that turn's delivered answer (#3429-class, model-steerable). So
905
- // a positive tier NEVER overrides an in-window handback; only the
906
- // FRAMEWORK-owned `live` tier (the live `currentTurn`, not model-derived,
907
- // and structurally unable to collide with an ended turn's flush record —
908
- // `decideSupersede` requires same turnId) may bypass a handback window.
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.
909
910
  //
910
- // The content gate is therefore kept whenever a handback WAS enqueued in the
911
- // window (any non-live tier): the late reply might BE that handback, so
912
- // genuinely-new content sends fresh (two messages, #3429 preserved).
913
- // Concurrency note: a case-A own reply coinciding with an unrelated
914
- // background handback in the same ≤TTL window degrades to today's behaviour
915
- // (two messages) — safe (never a silent drop/edit), just not collapsed.
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).
916
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.
917
940
  const handbackAt = getLastSubagentHandbackAt(chat_id)
918
941
  const now = Date.now()
919
942
  const handbackCouldOwnReply =
@@ -921,10 +944,26 @@ export async function sendReply(
921
944
  ownerEndedAt != null &&
922
945
  handbackAt > ownerEndedAt &&
923
946
  now - handbackAt <= DEFAULT_SUPERSEDE_TTL_MS
924
- const replyIsOwnAnswer = ownerTier === 'live' || !handbackCouldOwnReply
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)
925
964
  const decision = flushedTurnSupersede.take(
926
965
  chat_id,
927
- replyThreadId,
966
+ gateThreadId,
928
967
  { liveTurnId: resolvedTurnId, replyText: text, positiveAttribution: replyIsOwnAnswer, now },
929
968
  )
930
969
  if (decision.supersede) {
@@ -31,6 +31,7 @@
31
31
 
32
32
  import type { InboundMessage } from './ipc-protocol.js'
33
33
  import type { InboundSpool } from './inbound-spool.js'
34
+ import { stampsHandbackMarker } from './subagent-handback-marker.js'
34
35
 
35
36
  /** Default cap per agent. Tuned for `should fit a reasonable backlog of
36
37
  * approval cards stacked while bridge is offline` but no more. */
@@ -82,13 +83,17 @@ export interface PendingInboundBufferOptions {
82
83
  /**
83
84
  * fix/backstop-duplicate-reply MUST-FIX 2 — called on every push of a
84
85
  * `subagent_handback` envelope (live synthesis AND boot-replay re-push),
85
- * carrying the envelope's `chatId` and its own `ts` (ms). The gateway wires
86
- * this to the per-chat subagent-handback marker so the supersede path can tell
87
- * a flushed turn's own late reply from a background handback attributed to it
88
- * INCLUDING after a restart, where the only handback push is the replay.
89
- * Best-effort: a throw here never breaks the push hot path.
86
+ * carrying the envelope's `chatId`, its `threadId` (the originating forum
87
+ * topic, or undefined for a DM), and its own `ts` (ms). The gateway wires this
88
+ * to the per-chat/thread subagent-handback marker so the supersede path can
89
+ * tell a flushed turn's own late reply from a background handback attributed to
90
+ * it INCLUDING after a restart, where the only handback push is the replay.
91
+ * The `threadId` is passed so the marker keys on the SAME `chatId|threadId`
92
+ * lane the supersede registry uses (dup-audit F2): a handback in one topic must
93
+ * not hold the content gate open in another. Best-effort: a throw here never
94
+ * breaks the push hot path.
90
95
  */
91
- onHandbackEnqueue?: (chatId: string, ts: number) => void
96
+ onHandbackEnqueue?: (chatId: string, threadId: number | undefined, ts: number) => void
92
97
  }
93
98
 
94
99
  /**
@@ -362,9 +367,17 @@ export function createPendingInboundBuffer(
362
367
  // content gate → silent edit-over-answer. Uses the envelope's own `ts`
363
368
  // (ms, `Date.now()`-derived at synthesis) so the marker reflects when the
364
369
  // handback actually happened, not the replay moment. Best-effort.
365
- if (msg.meta?.source === 'subagent_handback' && opts.onHandbackEnqueue != null) {
370
+ // F1 (dup-audit) the ONE chokepoint that decides which sources stamp the
371
+ // decoupled-completion marker, delegated to the single `stampsHandbackMarker`
372
+ // predicate (its membership is the invariant's only extension point). Every
373
+ // inbound — live synthesis AND boot-replay — funnels through this push(), so
374
+ // routing the decision here makes "a decoupled late-reply source stamps the
375
+ // marker" true BY CONSTRUCTION rather than by per-feature discipline.
376
+ if (stampsHandbackMarker(msg.meta?.source) && opts.onHandbackEnqueue != null) {
366
377
  try {
367
- opts.onHandbackEnqueue(msg.chatId, msg.ts)
378
+ // F2 (dup-audit): pass the envelope's originating topic so the marker
379
+ // keys on the same `chatId|threadId` lane as the supersede registry.
380
+ opts.onHandbackEnqueue(msg.chatId, msg.threadId, msg.ts)
368
381
  } catch {
369
382
  /* marker stamp is best-effort; never break the push hot path */
370
383
  }