switchroom 0.19.36 → 0.19.38

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.36", COMMIT_SHA = "ba505b15";
2123
+ var VERSION = "0.19.38", COMMIT_SHA = "9ec88983";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -31355,8 +31355,8 @@ into your system prompt every session; this is the conversational summary.
31355
31355
  Kicking off a worker to crawl the changelog.
31356
31356
 
31357
31357
  **What it'll do**
31358
- \u2022 pull every entry since v0.14
31359
- \u2022 flag anything user-facing
31358
+ - pull every entry since v0.14
31359
+ - flag anything user-facing
31360
31360
 
31361
31361
  **Back in** ~2 min with a synthesized summary.
31362
31362
  - Use backtick-wrapped \`inline code\` for filenames, commands, and
@@ -31540,7 +31540,8 @@ You're writing for a phone screen in Telegram. Every reply renders as rich Markd
31540
31540
  fenced code blocks ALWAYS with a language
31541
31541
  hint (\`\`\`diff, \`\`\`json, \`\`\`bash \u2014 bare fence only for non-code fixed-width output);
31542
31542
  and the flagship \u2014 the **expandable blockquote** \`**> first line\` + \`> continuation\`
31543
- for a long quote, stack trace, or detailed aside the reader can collapse. Use it
31543
+ for a long quote, stack trace, or detailed aside the reader can collapse. The \`**>\`
31544
+ opener must be at the line start (column 0) \u2014 never indented \u2014 or it won't parse. Use it
31544
31545
  whenever a bulky supporting block would otherwise dominate the message.
31545
31546
 
31546
31547
  Renders wrong on this path \u2014 never emit: underline (\`__x__\` renders as bold),
@@ -31623,6 +31624,8 @@ var init_scaffold = __esm(() => {
31623
31624
  "mcp__hindsight__list_banks",
31624
31625
  "mcp__hindsight__get_memory",
31625
31626
  "mcp__hindsight__list_memories",
31627
+ "mcp__hindsight__invalidate_memory",
31628
+ "mcp__hindsight__update_memory",
31626
31629
  "mcp__hindsight__get_document",
31627
31630
  "mcp__hindsight__list_documents",
31628
31631
  "mcp__hindsight__delete_document",
@@ -45991,13 +45994,15 @@ var init_install_cron = () => {};
45991
45994
  function inParts(memories) {
45992
45995
  return Math.round(memories * PARTS_PER_MEMORY);
45993
45996
  }
45994
- var RING_MAX = 8, MAX_SAMPLE_AGE_MS, MIN_RETAIN_SAMPLES = 20, RETAIN_FAILURE_RATE = 0.1, PARTS_PER_MEMORY = 6, QUEUE_FLOOR_MEMORIES = 100, QUEUE_FLOOR, QUEUE_GROWTH_FRACTION = 0.1, QUEUE_GROWTH_MIN_ABS_MEMORIES = 20, QUEUE_GROWTH_MIN_ABS, BREACHES_TO_FIRE_LEVEL = 2, BREACHES_TO_FIRE_EDGE = 1, CLEARS_TO_RESOLVE = 2, RENOTIFY_MS, DEFAULT_METRICS_URL = "http://127.0.0.1:18888/metrics", DEFAULT_CONTAINER = "switchroom-hindsight", METRICS_MAX_BYTES, METRICS_TIMEOUT_MS = 1e4, RECALL_OWN_BANK_TIMEOUT_WARN = 0.05, RECALL_OWN_BANK_TIMEOUT_PAGE = 0.15, RECALL_ZERO_MEMORY_WARN = 0.25, RECALL_ZERO_MEMORY_PAGE = 0.4, RECALL_POOL_MEDIAN_WARN = 8, RECALL_POOL_MEDIAN_PAGE = 3, RECALL_SCORE_P50_WARN = 0.02, RECALL_SCORE_P50_PAGE = 0.01, CONSOLIDATION_AGE_WARN_S, CONSOLIDATION_AGE_PAGE_S, CONSOLIDATION_FAILURE_STREAK_WARN = 3, CONSOLIDATION_FAILURE_STREAK_PAGE = 10, CONSOLIDATION_STREAK_RECENCY_S, PENDING_CONSOLIDATION_FLOOR = 500, PENDING_CONSOLIDATION_GROWTH_FRACTION = 0.5, PENDING_CONSOLIDATION_GROWTH_WARN_ABS = 500, PENDING_CONSOLIDATION_GROWTH_PAGE_ABS = 5000, VECTOR_INDEX_SAMPLE_ROWS = 3, RECALL_MIN_SAMPLES = 30, LLM_FAILURE_RATE_WARN = 0.02, LLM_FAILURE_RATE_PAGE = 0.1, LLM_MIN_SAMPLES = 20;
45997
+ var RING_MAX = 8, MAX_SAMPLE_AGE_MS, MIN_RETAIN_SAMPLES = 20, RETAIN_FAILURE_RATE = 0.1, PARTS_PER_MEMORY = 6, QUEUE_FLOOR_MEMORIES = 100, QUEUE_FLOOR, QUEUE_GROWTH_FRACTION = 0.1, QUEUE_GROWTH_MIN_ABS_MEMORIES = 20, QUEUE_GROWTH_MIN_ABS, BREACHES_TO_FIRE_LEVEL = 2, BREACHES_TO_FIRE_EDGE = 1, CLEARS_TO_RESOLVE = 2, RENOTIFY_MS, DEFAULT_METRICS_URL = "http://127.0.0.1:18888/metrics", DEFAULT_CONTAINER = "switchroom-hindsight", METRICS_MAX_BYTES, METRICS_TIMEOUT_MS = 1e4, RECALL_OWN_BANK_TIMEOUT_WARN = 0.05, RECALL_OWN_BANK_TIMEOUT_PAGE = 0.15, RECALL_ZERO_MEMORY_WARN = 0.25, RECALL_ZERO_MEMORY_PAGE = 0.4, RECALL_POOL_MEDIAN_WARN = 8, RECALL_POOL_MEDIAN_PAGE = 3, RECALL_SCORE_P50_WARN = 0.02, RECALL_SCORE_P50_PAGE = 0.01, RECALL_WALL_MS, CONSOLIDATION_AGE_WARN_S, CONSOLIDATION_AGE_PAGE_S, CONSOLIDATION_FAILURE_STREAK_WARN = 3, CONSOLIDATION_FAILURE_STREAK_PAGE = 10, CONSOLIDATION_STREAK_RECENCY_S, PENDING_CONSOLIDATION_FLOOR = 500, PENDING_CONSOLIDATION_GROWTH_FRACTION = 0.5, PENDING_CONSOLIDATION_GROWTH_WARN_ABS = 500, PENDING_CONSOLIDATION_GROWTH_PAGE_ABS = 5000, VECTOR_INDEX_SAMPLE_ROWS = 3, RECALL_MIN_SAMPLES = 30, LLM_FAILURE_RATE_WARN = 0.02, LLM_FAILURE_RATE_PAGE = 0.1, LLM_MIN_SAMPLES = 20;
45995
45998
  var init_thresholds = __esm(() => {
45999
+ init_hindsight_recall_tunables();
45996
46000
  MAX_SAMPLE_AGE_MS = 3 * 60 * 60000;
45997
46001
  QUEUE_FLOOR = inParts(QUEUE_FLOOR_MEMORIES);
45998
46002
  QUEUE_GROWTH_MIN_ABS = inParts(QUEUE_GROWTH_MIN_ABS_MEMORIES);
45999
46003
  RENOTIFY_MS = 6 * 60 * 60 * 1000;
46000
46004
  METRICS_MAX_BYTES = 16 * 1024 * 1024;
46005
+ RECALL_WALL_MS = resolveHindsightRecallTunables(undefined).parallelDeadlineSeconds * 1000;
46001
46006
  CONSOLIDATION_AGE_WARN_S = 2 * 60 * 60;
46002
46007
  CONSOLIDATION_AGE_PAGE_S = 12 * 60 * 60;
46003
46008
  CONSOLIDATION_STREAK_RECENCY_S = 2 * 60 * 60;
@@ -105309,7 +105314,7 @@ function latencySuffix(r) {
105309
105314
  return "";
105310
105315
  const n = typeof r.elapsedConsidered === "number" ? r.elapsedConsidered : 0;
105311
105316
  return `
105312
- recall wall time p95 ${Math.round(p95)}ms over ${n} fire(s) (not thresholded \u2014 the 8000ms per-bank budget leaves no healthy band below it)`;
105317
+ recall wall time p95 ${Math.round(p95)}ms over ${n} fire(s) (not thresholded \u2014 the ${RECALL_WALL_MS}ms recall deadline leaves no healthy band below it)`;
105313
105318
  }
105314
105319
  function evaluateRecallOwnBankTimeout(ring) {
105315
105320
  const signal = "recall-own-bank-timeout";
@@ -105908,14 +105913,25 @@ function runInstallCron(cronUser) {
105908
105913
  `);
105909
105914
  return 0;
105910
105915
  }
105911
- async function notifyOperator(agentsDir, text, log) {
105916
+ var PREFERRED_RELAY_AGENT = "overlord";
105917
+ function orderRelayCandidates(candidates, preferred = PREFERRED_RELAY_AGENT) {
105918
+ const first = candidates.filter((c) => c.agent === preferred);
105919
+ const rest = candidates.filter((c) => c.agent !== preferred);
105920
+ return [...first, ...rest];
105921
+ }
105922
+ function collectRelayCandidates(agentsDir) {
105912
105923
  const candidates = [];
105924
+ for (const name of readdirSync45(agentsDir).sort()) {
105925
+ const sock = resolve63(agentsDir, name, "telegram", "gateway.sock");
105926
+ if (existsSync112(sock))
105927
+ candidates.push({ agent: name, sock });
105928
+ }
105929
+ return orderRelayCandidates(candidates);
105930
+ }
105931
+ async function notifyOperator(agentsDir, text, log) {
105932
+ let candidates = [];
105913
105933
  try {
105914
- for (const name of readdirSync45(agentsDir).sort()) {
105915
- const sock = resolve63(agentsDir, name, "telegram", "gateway.sock");
105916
- if (existsSync112(sock))
105917
- candidates.push({ agent: name, sock });
105918
- }
105934
+ candidates = collectRelayCandidates(agentsDir);
105919
105935
  } catch (e) {
105920
105936
  log(`hindsight-watch: agents dir unreadable (${e.message})`);
105921
105937
  }
@@ -21311,7 +21311,7 @@ function allocateAgentUid(name) {
21311
21311
  }
21312
21312
 
21313
21313
  // src/build-info.ts
21314
- var VERSION = "0.19.36";
21314
+ var VERSION = "0.19.38";
21315
21315
 
21316
21316
  // src/setup/hindsight-recall-tunables.ts
21317
21317
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
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.36",
4
+ "version": "0.19.38",
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": {
@@ -94,7 +94,7 @@ const TOOL_SCHEMAS = [
94
94
  // client and this detail was being silently truncated away.
95
95
  'FORUM TOPICS: a reply is auto-routed back to the topic the question came from, so do NOT pass message_thread_id on a normal reply — pass the inbound\'s origin_turn_id instead, so the answer lands in the right topic even if a message from another topic arrived while you were working. message_thread_id is only for deliberately posting into a topic that is not the one you were asked in. ' +
96
96
  // Format modes: likewise moved out of the truncated instructions string.
97
- 'FORMAT: the default format is "html" write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Pass format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text sent verbatim.',
97
+ 'FORMAT: by default your text is rendered as rich GFM markdown (Bot API 10.1) write natural markdown (bold, italic, code, links, code blocks, lists, tables) and it renders as-is. Pass format: "text" to send plain text verbatim with no rendering. "html" and "markdownv2" are accepted legacy aliases that route through the exact same single rich GFM path — there is no separate HTML engine and no auto-escaping.',
98
98
  inputSchema: {
99
99
  type: 'object',
100
100
  properties: {
@@ -105,7 +105,7 @@ const TOOL_SCHEMAS = [
105
105
  message_thread_id: { type: 'string', description: 'Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified.' },
106
106
  origin_turn_id: { type: 'string', description: 'In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message\'s topic even if another topic\'s turn started meanwhile. Omit in DMs / single-topic chats.' },
107
107
  files: { type: 'array', items: { type: 'string' }, description: 'Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each. Telegram rejects photos with extreme dimensions (aspect ratio over ~10:1, width+height over 10000px, or over 10MB) — very tall images like full-page screenshots are auto-rerouted as documents; crop or split them first if the user should see them inline as photos.' },
108
- format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
108
+ format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path — no separate HTML engine, no auto-escaping." },
109
109
  disable_web_page_preview: { type: 'boolean', description: 'Disable link preview thumbnails. Default: true.' },
110
110
  protect_content: { type: 'boolean', description: 'When true, Telegram prevents the message from being forwarded or saved.' },
111
111
  quote_text: { type: 'string', description: 'Surgical quote: specific text to highlight from the reply_to message. Requires reply_to.' },
@@ -180,7 +180,7 @@ const TOOL_SCHEMAS = [
180
180
  chat_id: { type: 'string' },
181
181
  message_id: { type: 'string' },
182
182
  text: { type: 'string' },
183
- format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
183
+ format: { type: 'string', enum: ['html', 'markdownv2', 'text'], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path — no separate HTML engine, no auto-escaping." },
184
184
  },
185
185
  required: ['chat_id', 'message_id', 'text'],
186
186
  },
@@ -341,7 +341,7 @@ const TOOL_SCHEMAS = [
341
341
  type: 'object',
342
342
  properties: {
343
343
  chat_id: { type: 'string', description: 'Chat that should receive the question. Pass from inbound meta.' },
344
- question: { type: 'string', description: 'The question text. Plain text or HTML. Keep it short — buttons render below.' },
344
+ question: { type: 'string', description: 'The question text. Plain text or GFM markdown (rendered through the same rich path as replies). Keep it short — buttons render below.' },
345
345
  options: {
346
346
  type: 'array',
347
347
  items: { type: 'string' },
@@ -25061,7 +25061,7 @@ var mcp = new Server({ name: "telegram", version: "1.0.0" }, {
25061
25061
  var TOOL_SCHEMAS = [
25062
25062
  {
25063
25063
  name: "reply",
25064
- description: "Reply on Telegram. Pass chat_id from the inbound message. By default the reply is a quote-reply to the latest inbound user message in this chat+thread \u2014 pass quote:false to opt out, or pass an explicit reply_to to thread under a specific earlier message. files (absolute paths) attach images or documents. inline_keyboard adds tappable buttons (URL or callback) under the message \u2014 single-tap actions beat asking the user to type YES. " + "FORUM TOPICS: a reply is auto-routed back to the topic the question came from, so do NOT pass message_thread_id on a normal reply \u2014 pass the inbound's origin_turn_id instead, so the answer lands in the right topic even if a message from another topic arrived while you were working. message_thread_id is only for deliberately posting into a topic that is not the one you were asked in. " + 'FORMAT: the default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Pass format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text sent verbatim.',
25064
+ description: "Reply on Telegram. Pass chat_id from the inbound message. By default the reply is a quote-reply to the latest inbound user message in this chat+thread \u2014 pass quote:false to opt out, or pass an explicit reply_to to thread under a specific earlier message. files (absolute paths) attach images or documents. inline_keyboard adds tappable buttons (URL or callback) under the message \u2014 single-tap actions beat asking the user to type YES. " + "FORUM TOPICS: a reply is auto-routed back to the topic the question came from, so do NOT pass message_thread_id on a normal reply \u2014 pass the inbound's origin_turn_id instead, so the answer lands in the right topic even if a message from another topic arrived while you were working. message_thread_id is only for deliberately posting into a topic that is not the one you were asked in. " + 'FORMAT: by default your text is rendered as rich GFM markdown (Bot API 10.1) \u2014 write natural markdown (bold, italic, code, links, code blocks, lists, tables) and it renders as-is. Pass format: "text" to send plain text verbatim with no rendering. "html" and "markdownv2" are accepted legacy aliases that route through the exact same single rich GFM path \u2014 there is no separate HTML engine and no auto-escaping.',
25065
25065
  inputSchema: {
25066
25066
  type: "object",
25067
25067
  properties: {
@@ -25072,7 +25072,7 @@ var TOOL_SCHEMAS = [
25072
25072
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." },
25073
25073
  origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
25074
25074
  files: { type: "array", items: { type: "string" }, description: "Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each. Telegram rejects photos with extreme dimensions (aspect ratio over ~10:1, width+height over 10000px, or over 10MB) \u2014 very tall images like full-page screenshots are auto-rerouted as documents; crop or split them first if the user should see them inline as photos." },
25075
- format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
25075
+ format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path \u2014 no separate HTML engine, no auto-escaping." },
25076
25076
  disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
25077
25077
  protect_content: { type: "boolean", description: "When true, Telegram prevents the message from being forwarded or saved." },
25078
25078
  quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Requires reply_to." },
@@ -25146,7 +25146,7 @@ var TOOL_SCHEMAS = [
25146
25146
  chat_id: { type: "string" },
25147
25147
  message_id: { type: "string" },
25148
25148
  text: { type: "string" },
25149
- format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." }
25149
+ format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path \u2014 no separate HTML engine, no auto-escaping." }
25150
25150
  },
25151
25151
  required: ["chat_id", "message_id", "text"]
25152
25152
  }
@@ -25303,7 +25303,7 @@ var TOOL_SCHEMAS = [
25303
25303
  type: "object",
25304
25304
  properties: {
25305
25305
  chat_id: { type: "string", description: "Chat that should receive the question. Pass from inbound meta." },
25306
- question: { type: "string", description: "The question text. Plain text or HTML. Keep it short \u2014 buttons render below." },
25306
+ question: { type: "string", description: "The question text. Plain text or GFM markdown (rendered through the same rich path as replies). Keep it short \u2014 buttons render below." },
25307
25307
  options: {
25308
25308
  type: "array",
25309
25309
  items: { type: "string" },
@@ -6923,7 +6923,11 @@ function normalizePunctuation(text) {
6923
6923
  const escNonce = nonce.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6924
6924
  const linkRestoreRe = new RegExp(`\x00RML${escNonce}_(\\d+)\x00`, "g");
6925
6925
  const restoreLinks = (s) => s.replace(linkRestoreRe, (_m, idx) => linkMasks[Number(idx)] ?? _m);
6926
- let out = maskedAutolinks.replace(/(\S)[ \t][\u2014\u2013][ \t](?=(\S))/g, (_m, a, b) => /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `).replace(/(\w)\u2014(?=(\w))/g, (_m, a, b) => /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `).replace(/(\w)\u2013(?=\w)/g, "$1-");
6926
+ const rewriteDashes = (line) => line.replace(/(\S)[ \t][\u2014\u2013][ \t](?=(\S))/g, (_m, a, b) => /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `).replace(/(\w)\u2014(?=(\w))/g, (_m, a, b) => /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `).replace(/(\w)\u2013(?=\w)/g, "$1-");
6927
+ const isQuotedLine = (line) => isBlockquoteLine(line) || line.trimStart().startsWith("**>");
6928
+ let out = maskedAutolinks.split(`
6929
+ `).map((line) => isQuotedLine(line) ? line : rewriteDashes(line)).join(`
6930
+ `);
6927
6931
  out = restoreLinks(out);
6928
6932
  out = out.split(`
6929
6933
  `).map((line) => line.replace(/^([ \t]*)[\u2022\u00b7][ \t]+/, "$1- ")).join(`
@@ -13607,7 +13611,7 @@ function guardUnsupportedTokens(text) {
13607
13611
  var CARET_PAIR, FOOTNOTE_MARKER, DETAILS_BLOCK, ORPHAN_TAGS;
13608
13612
  var init_unsupported_token_guard = __esm(() => {
13609
13613
  CARET_PAIR = /\^([A-Za-z0-9]+)\^/g;
13610
- FOOTNOTE_MARKER = /\[\^\d+\](?!:)/g;
13614
+ FOOTNOTE_MARKER = /\[\^[A-Za-z0-9]{1,10}\](?!:)/g;
13611
13615
  DETAILS_BLOCK = /<details[^>]*>\s*(?:<summary[^>]*>([\s\S]*?)<\/summary>)?([\s\S]*?)<\/details>/gi;
13612
13616
  ORPHAN_TAGS = /<\/?(?:details|summary)[^>]*>/gi;
13613
13617
  });
@@ -53401,6 +53405,12 @@ function createHandbackPreturnSignal(deps) {
53401
53405
  entry.reapTimer = null;
53402
53406
  if (entry.consumed)
53403
53407
  return;
53408
+ if (deps.isClaudeBusy?.() === true) {
53409
+ log(`handback-preturn-signal: reap deferred key=${entry.statusKey} ` + `(claude busy \u2014 handback enqueued behind an in-flight turn, not orphaned)
53410
+ `);
53411
+ entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs);
53412
+ return;
53413
+ }
53404
53414
  entry.consumed = true;
53405
53415
  stopTypingUnlessTurnLive(entry, "orphan reap");
53406
53416
  if (entry.activityMessageId != null) {
@@ -99668,10 +99678,10 @@ function startOutboxSweep(deps) {
99668
99678
  }
99669
99679
 
99670
99680
  // ../src/build-info.ts
99671
- var VERSION2 = "0.19.36";
99672
- var COMMIT_SHA = "ba505b15";
99673
- var COMMIT_DATE = "2026-07-30T07:53:24Z";
99674
- var LATEST_PR = 4019;
99681
+ var VERSION2 = "0.19.38";
99682
+ var COMMIT_SHA = "9ec88983";
99683
+ var COMMIT_DATE = "2026-07-30T09:50:08Z";
99684
+ var LATEST_PR = 4031;
99675
99685
  var COMMITS_AHEAD_OF_TAG = 0;
99676
99686
 
99677
99687
  // gateway/boot-version.ts
@@ -104881,7 +104891,7 @@ async function runMidSessionCardReaper() {
104881
104891
  const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
104882
104892
  path: ACTIVITY_CARD_STORE_PATH,
104883
104893
  fs: activityCardStoreFs,
104884
- isLive: (record2) => topicKeys.has(record2.turnKey),
104894
+ isLive: (record2) => topicKeys.has(record2.turnKey) || handbackPreturnSignal.isPreTurnRecord(record2.turnKey) && isMachineInTurn(),
104885
104895
  ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
104886
104896
  now,
104887
104897
  finalizeCard: (record2) => {
@@ -107521,7 +107531,8 @@ var handbackPreturnSignal = createHandbackPreturnSignal({
107521
107531
  if (live == null || live.endedAt != null)
107522
107532
  return false;
107523
107533
  return statusKey(live.sessionChatId, live.sessionThreadId) === key;
107524
- }
107534
+ },
107535
+ isClaudeBusy: () => isMachineInTurn()
107525
107536
  });
107526
107537
  var memoryLegibilityStager = new MemoryLegibilityStager;
107527
107538
  function sendMemoryLegibilityLine(event, chatId, threadId) {
@@ -24827,7 +24827,7 @@ var init_bridge = __esm(async () => {
24827
24827
  TOOL_SCHEMAS = [
24828
24828
  {
24829
24829
  name: "reply",
24830
- description: "Reply on Telegram. Pass chat_id from the inbound message. By default the reply is a quote-reply to the latest inbound user message in this chat+thread \u2014 pass quote:false to opt out, or pass an explicit reply_to to thread under a specific earlier message. files (absolute paths) attach images or documents. inline_keyboard adds tappable buttons (URL or callback) under the message \u2014 single-tap actions beat asking the user to type YES. " + "FORUM TOPICS: a reply is auto-routed back to the topic the question came from, so do NOT pass message_thread_id on a normal reply \u2014 pass the inbound's origin_turn_id instead, so the answer lands in the right topic even if a message from another topic arrived while you were working. message_thread_id is only for deliberately posting into a topic that is not the one you were asked in. " + 'FORMAT: the default format is "html" \u2014 write natural markdown and it is auto-converted to Telegram HTML (bold, italic, code, links, code blocks). Pass format: "markdownv2" for MarkdownV2 with auto-escaping, or "text" for plain text sent verbatim.',
24830
+ description: "Reply on Telegram. Pass chat_id from the inbound message. By default the reply is a quote-reply to the latest inbound user message in this chat+thread \u2014 pass quote:false to opt out, or pass an explicit reply_to to thread under a specific earlier message. files (absolute paths) attach images or documents. inline_keyboard adds tappable buttons (URL or callback) under the message \u2014 single-tap actions beat asking the user to type YES. " + "FORUM TOPICS: a reply is auto-routed back to the topic the question came from, so do NOT pass message_thread_id on a normal reply \u2014 pass the inbound's origin_turn_id instead, so the answer lands in the right topic even if a message from another topic arrived while you were working. message_thread_id is only for deliberately posting into a topic that is not the one you were asked in. " + 'FORMAT: by default your text is rendered as rich GFM markdown (Bot API 10.1) \u2014 write natural markdown (bold, italic, code, links, code blocks, lists, tables) and it renders as-is. Pass format: "text" to send plain text verbatim with no rendering. "html" and "markdownv2" are accepted legacy aliases that route through the exact same single rich GFM path \u2014 there is no separate HTML engine and no auto-escaping.',
24831
24831
  inputSchema: {
24832
24832
  type: "object",
24833
24833
  properties: {
@@ -24838,7 +24838,7 @@ var init_bridge = __esm(async () => {
24838
24838
  message_thread_id: { type: "string", description: "Forum topic thread ID. Auto-applied from the last inbound message in the same chat if not specified." },
24839
24839
  origin_turn_id: { type: "string", description: "In a forum supergroup, pass back the origin_turn_id attribute from the <channel> message you are answering. It pins the reply to that message's topic even if another topic's turn started meanwhile. Omit in DMs / single-topic chats." },
24840
24840
  files: { type: "array", items: { type: "string" }, description: "Absolute file paths to attach. Images send as photos; other types as documents. Max 50MB each. Telegram rejects photos with extreme dimensions (aspect ratio over ~10:1, width+height over 10000px, or over 10MB) \u2014 very tall images like full-page screenshots are auto-rerouted as documents; crop or split them first if the user should see them inline as photos." },
24841
- format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." },
24841
+ format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path \u2014 no separate HTML engine, no auto-escaping." },
24842
24842
  disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
24843
24843
  protect_content: { type: "boolean", description: "When true, Telegram prevents the message from being forwarded or saved." },
24844
24844
  quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Requires reply_to." },
@@ -24912,7 +24912,7 @@ var init_bridge = __esm(async () => {
24912
24912
  chat_id: { type: "string" },
24913
24913
  message_id: { type: "string" },
24914
24914
  text: { type: "string" },
24915
- format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. 'html' (default) converts markdown to Telegram HTML." }
24915
+ format: { type: "string", enum: ["html", "markdownv2", "text"], description: "Rendering mode. Default renders your text as rich GFM markdown (Bot API 10.1). 'text' sends plain text verbatim. 'html' and 'markdownv2' are legacy aliases that route through the same single rich GFM path \u2014 no separate HTML engine, no auto-escaping." }
24916
24916
  },
24917
24917
  required: ["chat_id", "message_id", "text"]
24918
24918
  }
@@ -25069,7 +25069,7 @@ var init_bridge = __esm(async () => {
25069
25069
  type: "object",
25070
25070
  properties: {
25071
25071
  chat_id: { type: "string", description: "Chat that should receive the question. Pass from inbound meta." },
25072
- question: { type: "string", description: "The question text. Plain text or HTML. Keep it short \u2014 buttons render below." },
25072
+ question: { type: "string", description: "The question text. Plain text or GFM markdown (rendered through the same rich path as replies). Keep it short \u2014 buttons render below." },
25073
25073
  options: {
25074
25074
  type: "array",
25075
25075
  items: { type: "string" },
@@ -718,21 +718,37 @@ export function normalizePunctuation(text: string): string {
718
718
  const restoreLinks = (s: string): string =>
719
719
  s.replace(linkRestoreRe, (_m, idx: string) => linkMasks[Number(idx)] ?? _m)
720
720
 
721
+ // The dash rewrite runs per line so blockquote lines can be exempted:
722
+ // `>` blockquotes are reserved for VERBATIM quoted text, and rewriting an
723
+ // author's ` — ` to `, ` inside a quotation would corrupt what they quoted.
724
+ // The expandable-blockquote opener `**>` is a blockquote line too even though
725
+ // isBlockquoteLine (which keys on a leading `>`) doesn't catch the `**`
726
+ // prefix, so exempt it explicitly. Code spans and link hrefs stay masked
727
+ // throughout, so their dashes are already protected on every line.
728
+ const rewriteDashes = (line: string): string =>
729
+ line
730
+ // 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
731
+ // flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
732
+ // dashes ("a — b — c") all normalize in one pass — a consumed \S would
733
+ // swallow the char that anchors the next match.
734
+ .replace(/(\S)[ \t][—–][ \t](?=(\S))/g, (_m, a: string, b: string) =>
735
+ /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
736
+ )
737
+ // 2. Bare em-dash between word chars. Numeric range keeps a hyphen.
738
+ // Right flank is a lookahead for the same consecutive-match reason.
739
+ .replace(/(\w)—(?=(\w))/g, (_m, a: string, b: string) =>
740
+ /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
741
+ )
742
+ // 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
743
+ .replace(/(\w)–(?=\w)/g, '$1-')
744
+
745
+ const isQuotedLine = (line: string): boolean =>
746
+ isBlockquoteLine(line) || line.trimStart().startsWith('**>')
747
+
721
748
  let out = maskedAutolinks
722
- // 1. Space-flanked em/en dash. Numeric range keeps a hyphen. The right
723
- // flank is a LOOKAHEAD (captured, not consumed) so consecutive spaced
724
- // dashes ("a — b — c") all normalize in one pass — a consumed \S would
725
- // swallow the char that anchors the next match.
726
- .replace(/(\S)[ \t][—–][ \t](?=(\S))/g, (_m, a: string, b: string) =>
727
- /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
728
- )
729
- // 2. Bare em-dash between word chars. Numeric range keeps a hyphen.
730
- // Right flank is a lookahead for the same consecutive-match reason.
731
- .replace(/(\w)—(?=(\w))/g, (_m, a: string, b: string) =>
732
- /\d/.test(a) && /\d/.test(b) ? `${a}-` : `${a}, `,
733
- )
734
- // 3. Bare en-dash between word chars → hyphen (ranges: 2019–2024).
735
- .replace(/(\w)–(?=\w)/g, '$1-')
749
+ .split('\n')
750
+ .map((line) => (isQuotedLine(line) ? line : rewriteDashes(line)))
751
+ .join('\n')
736
752
 
737
753
  // Restore link hrefs now that the dash passes are done — before the bullet
738
754
  // pass and the code restore.
@@ -8909,10 +8909,14 @@ async function runMidSessionCardReaper(): Promise<void> {
8909
8909
  const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
8910
8910
  path: ACTIVITY_CARD_STORE_PATH,
8911
8911
  fs: activityCardStoreFs,
8912
- // A synthetic pre-turn record's key is never a live topic key, so it is
8913
- // correctly seen as ownerless here (the seam's own age-based self-reap is
8914
- // the fast path; this is the crash / long-lived backstop).
8915
- isLive: (record) => topicKeys.has(record.turnKey),
8912
+ // A synthetic pre-turn key reads as ownerless here (self-reap is the fast
8913
+ // path; this is the crash backstop). EXCEPTION: a pre-turn handback still
8914
+ // enqueued behind an in-flight turn is NOT an orphan keep it live while
8915
+ // claude is busy, else this 15-min backstop re-posts the false nudge the
8916
+ // self-reap gate removed. (Boot reaper stays ungated: boot is never mid-turn.)
8917
+ isLive: (record) =>
8918
+ topicKeys.has(record.turnKey) ||
8919
+ (handbackPreturnSignal.isPreTurnRecord(record.turnKey) && isMachineInTurn()),
8916
8920
  ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
8917
8921
  now,
8918
8922
  finalizeCard: (record) => {
@@ -13827,6 +13831,10 @@ const handbackPreturnSignal = createHandbackPreturnSignal({
13827
13831
  // dark-indicator bug). Flag-ON is already per-key; the check is a no-op there.
13828
13832
  return statusKey(live.sessionChatId, live.sessionThreadId) === key
13829
13833
  },
13834
+ // Queue-state gate for the orphan reap (see the signal's `isClaudeBusy` doc):
13835
+ // while a turn is in flight the handback is enqueued behind it, not orphaned.
13836
+ // False for both `bridge_alive_idle` and `bridge_dead` → genuine orphan reaps.
13837
+ isClaudeBusy: () => isMachineInTurn(),
13830
13838
  })
13831
13839
 
13832
13840
  /**
@@ -54,9 +54,23 @@
54
54
  * On adoption the durable record is re-keyed from its synthetic pre-turn key
55
55
  * to the real `statusKey` so that teardown matches.
56
56
  *
57
- * • NEVER-ADOPTED ORPHAN REAP. A degenerate case (bridge death after release,
58
- * no enqueue ever arrives) would leave a frozen card + a forever `typing…`
59
- * loop. The pre-turn card is persisted as a COMPLETE `ActivityCardRecord`
57
+ * • NEVER-ADOPTED ORPHAN REAP GATED ON A GLOBAL-BUSY QUEUE CHECK. A
58
+ * degenerate case (bridge death after release, no enqueue ever arrives)
59
+ * would leave a frozen card + a forever `typing…` loop. But the reap must
60
+ * fire ONLY for that genuine orphan, never for a handback that is correctly
61
+ * enqueued and simply waiting behind a long in-flight turn: claude has ONE
62
+ * bridge / ONE input queue, so a released handback mints its adopting turn
63
+ * only when claude next goes idle. A flat age-based reap cannot tell the two
64
+ * apart and (before this gate) posted a false "never started — needs a
65
+ * nudge" card every time a parent turn out-ran the timeout (the 3-cards-in-
66
+ * 4-min incident). The reap therefore consults `isClaudeBusy()` — the
67
+ * machine-level "a turn is in flight right now" signal — and DEFERS
68
+ * (re-arms for another `adoptTimeoutMs`) while claude is busy, finalizing
69
+ * only once claude is idle AND the handback still has not been adopted. That
70
+ * idle-with-no-adoption state is the genuine bridge-death / dropped-inbound
71
+ * degenerate case. Lengthening the flat timeout would merely move the false
72
+ * positive; the queue-state gate removes it. The pre-turn card is persisted
73
+ * as a COMPLETE `ActivityCardRecord`
60
74
  * under a SYNTHETIC `turnKey` (`preturn:<statusKey>:<startedAt>`) — synthetic
61
75
  * so a sibling REAL turn on the same topic key can neither upsert-clobber it
62
76
  * nor keep it "live" (the mid-session reaper's `isLive` keys on live topic
@@ -171,6 +185,22 @@ export interface HandbackPreturnSignalDeps {
171
185
  * canonical turn-end owns the stop. Optional; defaults to "no live turn"
172
186
  * (the pre-#3544 behaviour). */
173
187
  hasLiveTurn?: (statusKey: string) => boolean
188
+ /** True IFF claude is CURRENTLY producing a turn (ANY topic — the machine has
189
+ * ONE bridge / ONE input queue). Consulted by the orphan reap: a released
190
+ * handback is delivered into that single queue and only mints its adopting
191
+ * turn when claude next goes idle, so while claude is busy the handback is
192
+ * NOT orphaned — it is correctly enqueued BEHIND the in-flight turn (a long
193
+ * parent turn can run many minutes). Finalizing it as an orphan in that
194
+ * window posts a false "never started — needs a nudge" card. The reap
195
+ * therefore DEFERS (re-arms) while this is true and finalizes only once
196
+ * claude is idle and the handback still has not been adopted — the genuine
197
+ * bridge-death / dropped-inbound degenerate case the reap exists for.
198
+ * Distinct from `hasLiveTurn`, which is PER-KEY and only governs the
199
+ * typing-loop stop; this is the GLOBAL busy signal because the handback
200
+ * queues behind whatever turn is running, regardless of topic. Optional;
201
+ * defaults to "not busy" (immediate reap — the pre-fix behaviour), so the
202
+ * genuine-orphan path is unchanged. */
203
+ isClaudeBusy?: () => boolean
174
204
  now?: () => number
175
205
  /** Debounce before painting the pre-turn card (kills sub-second flicker). */
176
206
  debounceMs?: number
@@ -349,6 +379,25 @@ export function createHandbackPreturnSignal(
349
379
  function reap(entry: PreTurnEntry): void {
350
380
  entry.reapTimer = null
351
381
  if (entry.consumed) return
382
+ // PRIMARY GATE (queue state, not a flat timeout): a released handback is
383
+ // delivered into claude's SINGLE input queue and mints its adopting turn
384
+ // only when claude next goes idle. If claude is STILL in a turn, this
385
+ // handback is not orphaned — it is correctly enqueued behind the in-flight
386
+ // turn (a long parent turn can run many minutes). Finalizing now would post
387
+ // a false "never started — needs a nudge" card (the 3-cards-in-4-min
388
+ // incident). Defer: re-arm and re-check. We finalize only once claude is
389
+ // idle AND the handback still has not been adopted (the genuine bridge-
390
+ // death / dropped-inbound case). This cannot loop forever on a healthy
391
+ // agent: claude processes its queue FIFO, so the handback is either adopted
392
+ // (cancelling the reap) or claude eventually goes idle (letting it fire).
393
+ if (deps.isClaudeBusy?.() === true) {
394
+ log(
395
+ `handback-preturn-signal: reap deferred key=${entry.statusKey} ` +
396
+ `(claude busy — handback enqueued behind an in-flight turn, not orphaned)\n`,
397
+ )
398
+ entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs)
399
+ return
400
+ }
352
401
  entry.consumed = true
353
402
  // Stop the forever-running typing loop and finalize the frozen card.
354
403
  stopTypingUnlessTurnLive(entry, 'orphan reap')
@@ -59,12 +59,16 @@ import { splitProtectedSegments } from "./code-segments.js";
59
59
  * `a^2+b^2=c^2`, `2^8`, and `x^n` all pass through untouched. */
60
60
  const CARET_PAIR = /\^([A-Za-z0-9]+)\^/g;
61
61
 
62
- /** Footnote reference marker `[^N]` (digits only) NOT immediately followed by
63
- * `:` (which would make it a footnote DEFINITION line we leave intact). Removed
64
- * entirely. Restricting the id to digits keeps this off in-prose bracket
65
- * literals like `array[^index]` and regex-ish `[^/]`, which are NOT footnotes
66
- * and would otherwise be silently eaten. */
67
- const FOOTNOTE_MARKER = /\[\^\d+\](?!:)/g;
62
+ /** Footnote reference marker `[^id]` where the id is a short alphanumeric run
63
+ * (`[^1]`, `[^note]`, `[^ref]`) NOT immediately followed by `:` (which would
64
+ * make it a footnote DEFINITION line we leave intact). Removed entirely.
65
+ * Requiring the id to be 1–10 ALPHANUMERIC chars keeps this off regex-ish
66
+ * literals like `[^/]`, `[^\s]`, `[^-a-z]`, whose bodies contain punctuation
67
+ * and so never match. In-prose subscript-ish `array[^i]` outside a code span
68
+ * is a rare theoretical false positive (an `i` id matches); code spans are
69
+ * masked upstream by splitProtectedSegments so real code is safe, and prose
70
+ * that writes a literal `[^i]` reads as footnote noise anyway. */
71
+ const FOOTNOTE_MARKER = /\[\^[A-Za-z0-9]{1,10}\](?!:)/g;
68
72
 
69
73
  /** `<details>…</details>` with an optional leading `<summary>…</summary>`.
70
74
  * Dot-all via `[\s\S]`; non-greedy so adjacent blocks don't merge. */
@@ -245,6 +245,24 @@ describe('normalizePunctuation', () => {
245
245
  expect(normalizePunctuation('<not—a—url>')).toBe('<not, a, url>')
246
246
  })
247
247
 
248
+ test('does NOT rewrite a dash inside a `>` blockquote (verbatim quote)', () => {
249
+ // `>` blockquotes hold quoted text the author is reproducing verbatim;
250
+ // rewriting their em-dash to `, ` corrupts the quotation.
251
+ expect(normalizePunctuation('> she said — plainly — no')).toBe(
252
+ '> she said — plainly — no',
253
+ )
254
+ // Indented blockquote line is still a blockquote.
255
+ expect(normalizePunctuation(' > quoted — text')).toBe(' > quoted — text')
256
+ // The expandable-blockquote opener `**>` is a blockquote line too.
257
+ expect(normalizePunctuation('**> first — line\n> continues — here')).toBe(
258
+ '**> first — line\n> continues — here',
259
+ )
260
+ // Prose OUTSIDE the quote still normalizes; only the quoted line is spared.
261
+ expect(normalizePunctuation('intro — here\n> quote — verbatim\nafter — end')).toBe(
262
+ 'intro, here\n> quote — verbatim\nafter, end',
263
+ )
264
+ })
265
+
248
266
  test('idempotent', () => {
249
267
  const once = normalizePunctuation('a — b\n• c\nd—e\n1–2')
250
268
  expect(normalizePunctuation(once)).toBe(once)
@@ -300,6 +300,73 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
300
300
  expect(h.signal.pendingCount()).toBe(0) // map empty
301
301
  })
302
302
 
303
+ // ── queued-behind-a-long-turn is NOT an orphan (the 3-cards-in-4-min bug) ──
304
+ // A handback released while the parent is mid-turn on a long task is delivered
305
+ // into claude's single input queue and mints its adopting turn only when the
306
+ // parent turn ends. The flat 30 s reap could not tell "enqueued and waiting"
307
+ // from "bridge died, never enqueued" and finalized a false "handback never
308
+ // started — needs a nudge" card. With the queue-state gate (`isClaudeBusy`)
309
+ // the reap DEFERS while claude is busy and the late adoption finalizes the
310
+ // card honestly. This test FAILS on the pre-fix flat-30 s behaviour: without
311
+ // the gate the reap fires at 30 s, finalizes the orphan text, and consumes the
312
+ // entry so the later adoption returns null.
313
+ it('does NOT finalize a handback whose turn adopts late because the parent was busy', async () => {
314
+ let busy = false
315
+ const h = makeHarness({ isClaudeBusy: () => busy })
316
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatBusy', messageId: 700 }))
317
+ await h.sched.advance(700) // debounce: card painted, reap armed
318
+ const resolvedId = await h.openCard.mock.results[0]!.value
319
+ expect(h.records.size).toBe(1)
320
+
321
+ // The parent turn is now in flight (a long Wave-2 assembly). The handback is
322
+ // enqueued behind it.
323
+ busy = true
324
+
325
+ // Two full reap intervals elapse — well beyond the flat 30 s. The reap must
326
+ // DEFER each time: no false orphan card, entry still live, typing still lit.
327
+ await h.sched.advance(30_000)
328
+ await h.sched.advance(30_000)
329
+ expect(h.finalizedIds).toEqual([]) // NEVER posted the "never started" nudge
330
+ expect(h.signal.pendingCount()).toBe(1) // still queued, not reaped
331
+ expect(h.typing.activeCount()).toBe(1) // indicator kept while queued
332
+
333
+ // The parent turn finally ends and the handback mints its own turn: it
334
+ // adopts the SAME card (an edit, not a second send / not an orphan finalize).
335
+ busy = false
336
+ const turnId = deriveTurnId('chatBusy', null, 700)!
337
+ const adoption = h.signal.tryAdopt(turnId)
338
+ expect(adoption).not.toBeNull()
339
+ expect(adoption!.activityMessageId).toBe(resolvedId)
340
+ expect(h.finalizedIds).toEqual([]) // adopted, never finalized as orphan
341
+ expect(h.openCard).toHaveBeenCalledTimes(1) // no double-send
342
+ expect(h.signal.pendingCount()).toBe(0)
343
+ })
344
+
345
+ // The deferred reap is not a leak: once claude goes idle and the handback has
346
+ // STILL not been adopted (genuine bridge-death / dropped inbound), the re-armed
347
+ // reap finalizes honestly — the degenerate case the reap exists for.
348
+ it('finalizes a genuine orphan once claude goes idle, even after deferring while busy', async () => {
349
+ let busy = true
350
+ const h = makeHarness({ isClaudeBusy: () => busy })
351
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatIdleOrphan', messageId: 710 }))
352
+ await h.sched.advance(700)
353
+ const resolvedId = await h.openCard.mock.results[0]!.value
354
+
355
+ // Busy: the first reap defers.
356
+ await h.sched.advance(30_000)
357
+ expect(h.finalizedIds).toEqual([])
358
+ expect(h.signal.pendingCount()).toBe(1)
359
+
360
+ // Claude goes idle but NO adopting turn ever mints (the inbound was dropped).
361
+ // The re-armed reap now finalizes the genuine orphan.
362
+ busy = false
363
+ await h.sched.advance(30_000)
364
+ expect(h.finalizedIds).toEqual([resolvedId])
365
+ expect(h.typing.activeCount()).toBe(0)
366
+ expect(h.records.size).toBe(0)
367
+ expect(h.signal.pendingCount()).toBe(0)
368
+ })
369
+
303
370
  it('identity race: a user inbound on the same topic never mis-adopts the handback, no double-send', async () => {
304
371
  const h = makeHarness()
305
372
  h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatE', messageId: 200 }))
@@ -162,8 +162,15 @@ describe("moved instruction content landed in tool descriptions", () => {
162
162
 
163
163
  it("format modes landed in the reply description", () => {
164
164
  expect(bridgeCode).toContain("FORMAT:");
165
- expect(bridgeCode).toMatch(/default format is "html"/);
165
+ // The description must describe the ONE real render path (rich GFM), and
166
+ // still name the legacy aliases so the enum values stay documented. It must
167
+ // NOT resurrect the deleted-engine claims (html→HTML conversion, MarkdownV2
168
+ // auto-escaping) — those are false at HEAD (single GFM path, no escaping).
169
+ expect(bridgeCode).toMatch(/rich GFM markdown/);
166
170
  expect(bridgeCode).toContain("markdownv2");
171
+ expect(bridgeCode).not.toMatch(/default format is "html"/);
172
+ expect(bridgeCode).not.toMatch(/auto-converted to Telegram HTML/);
173
+ expect(bridgeCode).not.toMatch(/MarkdownV2 with auto-escaping/);
167
174
  });
168
175
 
169
176
  it("history-buffer rationale landed in get_recent_messages", () => {
@@ -71,15 +71,46 @@ describe("guardUnsupportedTokens — deterministic send-time repair", () => {
71
71
  expect(guardUnsupportedTokens("x^2^ metres")).toBe("x2 metres");
72
72
  });
73
73
 
74
- it("leaves in-prose bracket literals intact, repairs real numeric footnotes", () => {
75
- // `array[^index]` is a negated-char-class / index literal, not a footnote.
76
- expect(guardUnsupportedTokens("array[^index] lookup")).toBe(
77
- "array[^index] lookup",
74
+ it("repairs alphanumeric footnote markers, not just numeric", () => {
75
+ // POLISH-4: widened from digits-only so `[^note]`/`[^ref]` no longer ship
76
+ // as literal bracket noise. Each would survive UNTOUCHED on origin/main.
77
+ expect(guardUnsupportedTokens("see the note[^note] here")).toBe(
78
+ "see the note here",
79
+ );
80
+ expect(guardUnsupportedTokens("as shown[^ref] above")).toBe(
81
+ "as shown above",
82
+ );
83
+ expect(guardUnsupportedTokens("point[^fn1] made")).toBe("point made");
84
+ // A single-letter id is a footnote too.
85
+ expect(guardUnsupportedTokens("claim[^a] holds")).toBe("claim holds");
86
+ // Definition lines with an alphanumeric id are still left intact (`(?!:)`).
87
+ expect(guardUnsupportedTokens("[^note]: the definition")).toBe(
88
+ "[^note]: the definition",
78
89
  );
90
+ });
91
+
92
+ it("leaves regex-literal negated char classes intact", () => {
93
+ // Real negated char classes contain punctuation / ranges / escapes, so the
94
+ // alphanumeric-only id requirement excludes them — these stay verbatim.
79
95
  expect(guardUnsupportedTokens("use [^/] to match")).toBe(
80
96
  "use [^/] to match",
81
97
  );
82
- // A real numeric footnote marker is still stripped.
98
+ expect(guardUnsupportedTokens("strip [^a-z] chars")).toBe(
99
+ "strip [^a-z] chars",
100
+ );
101
+ expect(guardUnsupportedTokens('match [^"] here')).toBe('match [^"] here');
102
+ // Accepted tradeoff (task POLISH-4): a PURE-alphanumeric negated class in
103
+ // BARE prose like `array[^index]` is now treated as a footnote and stripped.
104
+ // This is rare — regex/index literals in real prose live in code spans,
105
+ // which splitProtectedSegments masks (see code-span test below).
106
+ expect(guardUnsupportedTokens("array[^index] lookup")).toBe("array lookup");
107
+ // …but inside a code span the same literal is untouched.
108
+ expect(guardUnsupportedTokens("`array[^index]` lookup")).toBe(
109
+ "`array[^index]` lookup",
110
+ );
111
+ });
112
+
113
+ it("repairs a real numeric footnote marker", () => {
83
114
  expect(guardUnsupportedTokens("see the note[^1] here")).toBe(
84
115
  "see the note here",
85
116
  );