switchroom 0.19.42 β†’ 0.19.43

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.
Files changed (72) hide show
  1. package/dist/agent-scheduler/index.js +1 -1
  2. package/dist/auth-broker/index.js +32 -7
  3. package/dist/cli/notion-write-pretool.mjs +1 -1
  4. package/dist/cli/switchroom.js +1187 -669
  5. package/dist/host-control/main.js +205 -28
  6. package/dist/vault/approvals/kernel-server.js +32 -7
  7. package/dist/vault/broker/server.js +32 -7
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1225 -614
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/boot-card.ts +7 -2
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  20. package/telegram-plugin/gateway/gateway.ts +49 -51
  21. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  22. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  23. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  24. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  25. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  26. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  27. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  28. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  29. package/telegram-plugin/gateway/stream-render.ts +5 -0
  30. package/telegram-plugin/gateway/update-announce.ts +11 -4
  31. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  32. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  33. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  34. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  35. package/telegram-plugin/outbox.ts +103 -2
  36. package/telegram-plugin/render/rich-render.ts +10 -2
  37. package/telegram-plugin/reply-quote.ts +150 -0
  38. package/telegram-plugin/retry-api-call.ts +254 -11
  39. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  40. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  41. package/telegram-plugin/silent-end.ts +34 -2
  42. package/telegram-plugin/status-no-truncate.ts +31 -16
  43. package/telegram-plugin/stream-controller.ts +44 -9
  44. package/telegram-plugin/stream-reply-handler.ts +5 -3
  45. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  46. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  47. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  48. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  49. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  50. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  51. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  52. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  53. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  54. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  55. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  56. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  57. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  58. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  59. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  60. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  61. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  62. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  63. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  64. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  65. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  66. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  68. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  69. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  70. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  71. package/telegram-plugin/tool-activity-summary.ts +58 -30
  72. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
@@ -16996,7 +16996,10 @@ var init_plugin_logger = __esm(() => {
16996
16996
  });
16997
16997
 
16998
16998
  // format.ts
16999
- var init_format = () => {};
16999
+ var DELIMITER_RUN_CHARS;
17000
+ var init_format = __esm(() => {
17001
+ DELIMITER_RUN_CHARS = new Set(["*", "_", "`", "~", "|"]);
17002
+ });
17000
17003
 
17001
17004
  // raw-error-scrub.ts
17002
17005
  function extractRequestId(raw) {
@@ -24849,7 +24852,7 @@ var init_bridge = __esm(async () => {
24849
24852
  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." },
24850
24853
  disable_web_page_preview: { type: "boolean", description: "Disable link preview thumbnails. Default: true." },
24851
24854
  protect_content: { type: "boolean", description: "When true, Telegram prevents the message from being forwarded or saved." },
24852
- quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Requires reply_to." },
24855
+ quote_text: { type: "string", description: "Surgical quote: specific text to highlight from the reply_to message. Must be an EXACT substring of that message (copy it verbatim, do not paraphrase; max 1024 chars) \u2014 a quote Telegram cannot find is dropped and the reply lands unquoted. Requires reply_to." },
24853
24856
  disable_notification: { type: "boolean", description: 'When true, Telegram delivers the message silently \u2014 no device ping for this user. Default false (pings). Use true for mid-turn updates ("still working through X") so only the final answer pings. Always omit (or pass false) on the final answer of a turn.' },
24854
24857
  inline_keyboard: {
24855
24858
  type: "array",
@@ -362,9 +362,11 @@ export function writeFlood429Ledger(
362
362
  * call at the ~5s cadence a long ban produces, because the fold collapses
363
363
  * those into a single episode instead of growing the file.
364
364
  *
365
- * Read-fold-write is not atomic across processes: the gateway and the MCP
366
- * server's `createRobustApiCall` share one state dir, so a simultaneous 429 in
367
- * both could drop one episode. That is deliberate β€” the alternative is a lock
365
+ * Read-fold-write is not atomic across processes. Today every writer lives in
366
+ * the gateway process (its two `createRetryApiCall` wirings plus the outbox
367
+ * sweep's, all on one event loop), but the ledger is keyed by state DIR, so a
368
+ * second process wiring the breaker could drop one episode on a simultaneous
369
+ * 429. That is deliberate β€” the alternative is a lock
368
370
  * on the send path's error handler, and the cost of losing an episode is
369
371
  * nil: a penalty stays on the ledger for seven days and is re-recorded by the
370
372
  * next 429, so no verdict tier turns on a single write landing.
@@ -235,9 +235,17 @@ export function writeFloodState(
235
235
  * classifies it (`src/cli/doctor-flood-pressure.ts`).
236
236
  *
237
237
  * Recording here rather than at the gateway callsites is deliberate: this is
238
- * the one function EVERY `onFloodWait` wiring goes through (gateway.ts's two
239
- * hooks and `shared/bot-runtime.ts`'s `createRobustApiCall`), so no future
240
- * callsite can record a window without also recording its history.
238
+ * the one function EVERY `onFloodWait` wiring goes through, so no future
239
+ * callsite can record a window without also recording its history. The live
240
+ * wirings are `gateway/gateway.ts:5715` (`robustApiCall`) and `:5849`
241
+ * (`nonEssentialApiCall`), plus `gateway/outbox-sweep.ts:582` β€” and
242
+ * `scripts/check-retry-flood-hooks.mjs` fails the build if a new
243
+ * `createRetryApiCall` site omits either breaker hook.
244
+ *
245
+ * (Until #3863 this docblock also cited `shared/bot-runtime.ts`'s
246
+ * `createRobustApiCall` as a live wiring. It had zero production callers and
247
+ * its breaker hooks were OPTIONAL, so it was deleted rather than counted.
248
+ * Cite only wirings the lint gate can see.)
241
249
  */
242
250
  export function makeFloodWaitRecorder(
243
251
  path: string,
@@ -30,6 +30,20 @@
30
30
  */
31
31
  export const RICH_MESSAGE_MAX_CHARS = 32768
32
32
 
33
+ /**
34
+ * The legacy plain-text `sendMessage` / `editMessageText` wire cap (4096
35
+ * UTF-16 units). It does NOT apply to the rich path (`sendRichMessage`, up to
36
+ * {@link RICH_MESSAGE_MAX_CHARS}), but it DOES apply the moment a send path
37
+ * DEGRADES to the plain endpoint β€” the parse-reject fallbacks that resend a
38
+ * rich chunk as plain text, and `renderSafe`'s `mode: "plain"` results.
39
+ *
40
+ * Lives here (next to the rich cap and the splitters) rather than in
41
+ * `render/rich-render.ts` so every plain-degradation site can re-cap without
42
+ * importing the renderer; `render/rich-render.ts` re-exports it for its
43
+ * existing importers.
44
+ */
45
+ export const PLAIN_TEXT_MAX_CHARS = 4096
46
+
33
47
  /**
34
48
  * Escape the GFM-markdown special characters so a dynamic value
35
49
  * (a filename, an id, arbitrary user text) renders LITERALLY inside a
@@ -782,8 +796,16 @@ function isFullyBolded(fragment: string): boolean {
782
796
  * treated as a legitimate pseudo-heading (`**Section**`) rather than an
783
797
  * over-bolded paragraph. Single source of truth for BOTH the per-block rule
784
798
  * and the global-ratio heading exemption.
799
+ *
800
+ * 64, not 48: at 48 a legitimate long section label
801
+ * (`**Deployment status across all three regions:**` β€” 50 chars with markers)
802
+ * was read as an over-bolded paragraph and flattened. 64 still bounds the
803
+ * exemption to something that reads as a label on a phone; the alternative of
804
+ * exempting any fully-bold line ending in `:` regardless of length was
805
+ * rejected because an arbitrarily long bolded paragraph that happens to end
806
+ * in a colon would then escape the tripwire entirely.
785
807
  */
786
- const PSEUDO_HEADING_MAX_CHARS = 48
808
+ const PSEUDO_HEADING_MAX_CHARS = 64
787
809
 
788
810
  /**
789
811
  * True when a blank-line-delimited block is a single-line pseudo-heading: one
@@ -828,7 +850,12 @@ function unbold(fragment: string): string {
828
850
  * encourages) and is NOT stripped β€” neither by the per-block rule NOR by
829
851
  * the global-ratio rule (it still counts toward the global ratio, so a
830
852
  * genuinely over-bolded message still trips, but its section headings
831
- * survive instead of the whole reply going plain).
853
+ * survive instead of the whole reply going plain). The one exception:
854
+ * when EVERY block of the message is such a heading there is no body to
855
+ * preserve contrast against, so the global rule strips them all rather
856
+ * than leaving a 100%-bold message untouched. A block that is nothing but
857
+ * masked code (a bare fence, a lone inline span) does NOT count as body
858
+ * for that test β€” see the note at the call site.
832
859
  * - A list with any non-fully-bolded item is left alone.
833
860
  *
834
861
  * Code spans/fences are masked (maskCodeRegions) and never counted or
@@ -862,8 +889,33 @@ export function stripExcessBold(
862
889
  // Clearly over-bolded β€” strip every bold span, keeping the text, EXCEPT
863
890
  // standalone short pseudo-heading blocks (`**Section**`), which stay bold
864
891
  // so a bold-dense digest keeps its section headings.
892
+ //
893
+ // The exemption only makes sense when there is body text for the headings
894
+ // to stand out FROM. A message whose every block is a pseudo-heading has
895
+ // no body: exempting all of them leaves a 100%-bold message untouched and
896
+ // unlogged. In that case the exemption is dropped and the whole message is
897
+ // stripped, which is what the ratio rule says.
898
+ //
899
+ // "Body" here means VISIBLE (non-code) text, so a block that is nothing but
900
+ // masked code is skipped rather than counted as body (#4114). Masked code
901
+ // is invisible to every other measurement in this function β€” it is stripped
902
+ // out of `visible` before the ratio is taken β€” so counting a bare fence as
903
+ // body would be the one place code influences the bold decision, and it
904
+ // would do so by RESTORING the exact #4017 symptom: a headings-plus-one-
905
+ // snippet digest (a very ordinary agent status report) stayed 100% bold
906
+ // with no onStrip line, because the code block broke `every(...)`. The
907
+ // contrast a code block provides is real but partial, and it does not make
908
+ // every remaining visible character being bold the right render.
909
+ //
910
+ // Filtering with `stripPlaceholders` (the masker's own "remove EVERY mask"
911
+ // primitive) rather than a fence-specific test means all mask kinds β€” the
912
+ // FENCE and INLINE prefixes today, anything maskCodeRegions adds later β€”
913
+ // are handled by construction instead of by enumeration.
914
+ const contentBlocks = blocks.filter((b) => stripPlaceholders(b).trim() !== '')
915
+ const allHeadings =
916
+ contentBlocks.length > 0 && contentBlocks.every(isPseudoHeadingBlock)
865
917
  const rebuilt = blocks.map((block) =>
866
- isPseudoHeadingBlock(block) ? block : unbold(block),
918
+ !allHeadings && isPseudoHeadingBlock(block) ? block : unbold(block),
867
919
  )
868
920
  const out = rejoinBlocks(masked, rebuilt)
869
921
  if (out !== masked) onStrip?.({ rule: 'global', ratio })
@@ -1160,6 +1212,41 @@ export function hardSliceToCap(text: string, cap = RICH_MESSAGE_MAX_CHARS): stri
1160
1212
  return out
1161
1213
  }
1162
1214
 
1215
+ /**
1216
+ * Re-cap a body that is about to be sent through the PLAIN `sendMessage` /
1217
+ * `editMessageText` endpoint (switchroom #4043).
1218
+ *
1219
+ * Every plain-text FALLBACK in the send path inherits a chunk that was split
1220
+ * for the RICH cap ({@link RICH_MESSAGE_MAX_CHARS} = 32768). Handing such a
1221
+ * chunk to the plain endpoint (which caps at {@link PLAIN_TEXT_MAX_CHARS} =
1222
+ * 4096) makes Telegram reject it with `message is too long` β€” so the fallback
1223
+ * that exists precisely to rescue a failed send fails too, and the user never
1224
+ * receives the message (in the outbox sweep it retries forever).
1225
+ *
1226
+ * Cuts at `splitMarkdownChunks`' safe boundaries first (never bisecting a
1227
+ * fenced block or a table row) and hard-slices any residual indivisible region,
1228
+ * so EVERY returned piece is guaranteed `<= cap`. Returns the input as a
1229
+ * single-element array when it already fits (the overwhelmingly common case,
1230
+ * byte-identical to the pre-fix behaviour).
1231
+ */
1232
+ export function splitPlainTextToCap(text: string, cap = PLAIN_TEXT_MAX_CHARS): string[] {
1233
+ if (cap <= 0) return [text]
1234
+ if (text.length <= cap) return [text]
1235
+ const out: string[] = []
1236
+ for (const piece of splitMarkdownChunks(text, cap)) {
1237
+ if (piece.length <= cap) {
1238
+ if (piece.length > 0) out.push(piece)
1239
+ continue
1240
+ }
1241
+ // `splitMarkdownChunks` emits an unsplittable region whole; hard-cut it so
1242
+ // the plain endpoint can actually accept it.
1243
+ for (const slice of hardSliceToCap(piece, cap)) {
1244
+ if (slice.length > 0) out.push(slice)
1245
+ }
1246
+ }
1247
+ return out.length > 0 ? out : [text]
1248
+ }
1249
+
1163
1250
  // ---------------------------------------------------------------------------
1164
1251
  // Markdown-aware chunking β€” never bisects a code fence or a table row
1165
1252
  // ---------------------------------------------------------------------------
@@ -1208,16 +1295,10 @@ export function splitMarkdownChunks(text: string, maxLen = RICH_MESSAGE_MAX_CHAR
1208
1295
  cut = spaceIdx
1209
1296
  }
1210
1297
 
1211
- // Back off so the cut doesn't fall inside an open ``` fence.
1212
- cut = backOffOpenFence(rest, cut)
1213
- // Back off so the cut doesn't bisect a table row (a line with `|`).
1214
- cut = backOffTableRow(rest, cut)
1215
- // Back off so the cut doesn't bisect an inline entity (`**bold**`,
1216
- // `` `code` ``, `_italic_`, `[label](href)`), which would leave an unclosed
1217
- // delimiter in the emitted chunk (Telegram then parse-rejects it to
1218
- // plaintext, or mis-renders the continuation). Runs LAST so it also cleans
1219
- // up a boundary the fence/table back-offs landed on.
1220
- cut = backOffOpenInline(rest, cut)
1298
+ // Back the cut off any markdown entity it would bisect β€” fenced block,
1299
+ // table row, inline span. One shared mechanism (`safeMarkdownCut`), also
1300
+ // used by the card fitter's last-resort clip (#4116).
1301
+ cut = safeMarkdownCut(rest, cut)
1221
1302
 
1222
1303
  if (cut <= 0) {
1223
1304
  // Could not find a safe boundary below maxLen β€” the region is one
@@ -1334,8 +1415,137 @@ const INLINE_SPAN_PATTERNS: readonly RegExp[] = [
1334
1415
  /__[^_\n]+__/g, // underline
1335
1416
  /(?<![\w*])_[^_\n]+_(?![\w*])/g, // italic (snake_case-guarded)
1336
1417
  /\[[^\]\n]*\]\([^)\n]*\)/g, // link [label](href)
1418
+ /~~[^~\n]+~~/g, // strikethrough
1419
+ /\|\|[^|\n]+\|\|/g, // spoiler
1337
1420
  ]
1338
1421
 
1422
+ /** Characters that form multi-character markdown delimiter runs (`**`, `~~`, `||`, ` ``` `). */
1423
+ const DELIMITER_RUN_CHARS = new Set(['*', '_', '`', '~', '|'])
1424
+
1425
+ /**
1426
+ * The opening delimiter of every entity kind {@link safeMarkdownCut} can
1427
+ * retreat past, anchored at the head of a string. Used ONLY by
1428
+ * {@link truncateMarkdownSafe}'s repair step β€” see the doc there for why
1429
+ * dropping an opener is the right repair. Kept adjacent to
1430
+ * `INLINE_SPAN_PATTERNS` so the two marker vocabularies stay in step.
1431
+ */
1432
+ const FENCE_OPENER_AT_HEAD = /^(\n?)(?:`{3,}|~{3,})[^\n]*/
1433
+ const INLINE_OPENER_AT_HEAD = /^(?:\*{1,3}|_{1,3}|`+|~~|\|\||\[)/
1434
+ const TABLE_ROW_OPENER_AT_HEAD = /^(\n?[ \t]*)\|/
1435
+
1436
+ /**
1437
+ * Bound on {@link truncateMarkdownSafe}'s opener-drop repair loop. Each pass
1438
+ * removes at least one character, so this only caps a pathologically nested
1439
+ * head (`***` + `` ` `` + `[` …); eight is far past anything a card produces.
1440
+ */
1441
+ const MAX_OPENER_REPAIRS = 8
1442
+
1443
+ /**
1444
+ * The largest index `<= cut` at which `text` can be split without bisecting a
1445
+ * markdown entity β€” the ONE place "where is it safe to cut markdown?" is
1446
+ * answered.
1447
+ *
1448
+ * Composes the three back-offs in the order the chunker has always applied
1449
+ * them (fenced block β†’ table row β†’ inline span; the inline pass runs last so
1450
+ * it also cleans a boundary the first two landed on), then refuses to split a
1451
+ * surrogate pair so the result is always valid UTF-16 as well as valid
1452
+ * markdown.
1453
+ *
1454
+ * Returns `0` when NO safe boundary exists at or below `cut` β€” i.e. an entity
1455
+ * that starts at index 0 swallows the whole window. Callers must handle that
1456
+ * case; see {@link truncateMarkdownSafe}.
1457
+ */
1458
+ export function safeMarkdownCut(text: string, cut: number): number {
1459
+ if (cut <= 0) return 0
1460
+ if (cut >= text.length) return text.length
1461
+ let safe = backOffOpenFence(text, cut)
1462
+ safe = backOffTableRow(text, safe)
1463
+ safe = backOffOpenInline(text, safe)
1464
+ // Never cut INSIDE a delimiter run: `**bold**` cut between the two closing
1465
+ // stars emits one lone `*` (odd marker count β†’ parse-reject), and the span
1466
+ // back-off above cannot see it because the run's first half is a complete
1467
+ // span's tail, not a straddle. Retreat to the run's start.
1468
+ while (safe > 0 && DELIMITER_RUN_CHARS.has(text[safe]) && text[safe - 1] === text[safe]) safe--
1469
+ if (safe <= 0) return 0
1470
+ // Never leave a lone high surrogate by cutting through an astral character.
1471
+ const prev = text.charCodeAt(safe - 1)
1472
+ const here = text.charCodeAt(safe)
1473
+ if (prev >= 0xd800 && prev <= 0xdbff && here >= 0xdc00 && here <= 0xdfff) safe -= 1
1474
+ return safe
1475
+ }
1476
+
1477
+ /**
1478
+ * Truncate `text` to at most `budget` characters such that the RESULT IS
1479
+ * ALWAYS PARSEABLE MARKDOWN β€” no half-open `**`/`_`/`` ` ``/`~~`/`||` run, no
1480
+ * unclosed fence, no bisected link or table row, no split surrogate pair.
1481
+ *
1482
+ * Why this exists (#4116): the card fitter's last-resort clip used to slice
1483
+ * the raw markdown by character count. A cut landing between a `**` pair
1484
+ * yields a body Telegram parse-REJECTS β€” so the code path that exists
1485
+ * specifically to guarantee delivery within the char budget could itself make
1486
+ * the send fail. A clipped card beats no card, but only if it can be sent.
1487
+ *
1488
+ * Two steps, in order:
1489
+ *
1490
+ * 1. **Back off** to the nearest safe boundary ({@link safeMarkdownCut}).
1491
+ * This is the normal case and it preserves formatting exactly: an entity
1492
+ * that straddles the limit simply doesn't make the cut.
1493
+ *
1494
+ * 2. **Drop the opener** when backing off would keep less than HALF the
1495
+ * window. That happens when a single entity is longer than the window β€”
1496
+ * `**<40k-char description>**`, the #3682 repro β€” and a back-off would
1497
+ * return a near-empty card, which is no better than the dropped card the
1498
+ * clip exists to prevent. The entity cannot render anyway (its closing
1499
+ * delimiter is past the window), so we drop its OPENING delimiter and ask
1500
+ * again: the text survives as literal prose, the markdown stays balanced,
1501
+ * and the card is delivered.
1502
+ *
1503
+ * The window END IS HELD FIXED IN SOURCE TERMS across a repair (`end` shrinks
1504
+ * by exactly the characters removed). Letting the window slide forward by the
1505
+ * dropped opener's width would pull the entity's CLOSING delimiter into the
1506
+ * output β€” an orphan closer, unbalanced again. That is a real defect this
1507
+ * function had before its property test swept every offset.
1508
+ *
1509
+ * The repair loop is bounded (each pass removes at least one character; at
1510
+ * most `MAX_OPENER_REPAIRS` passes) so a pathological body cannot spin.
1511
+ */
1512
+ export function truncateMarkdownSafe(text: string, budget: number): string {
1513
+ if (budget <= 0) return ''
1514
+ if (text.length <= budget) return text
1515
+ let t = text
1516
+ let end = budget
1517
+ for (let pass = 0; pass <= MAX_OPENER_REPAIRS; pass++) {
1518
+ const safe = safeMarkdownCut(t, end)
1519
+ // A back-off that keeps at least half the window is the good outcome: the
1520
+ // straddling entity is dropped whole and everything before it renders as
1521
+ // authored.
1522
+ if (safe * 2 >= end) return t.slice(0, safe)
1523
+ const repaired = dropLeadingOpener(t.slice(safe))
1524
+ // No opener to drop (unreachable in principle β€” every back-off above
1525
+ // retreats TO one). Prefer the short-but-valid back-off over a slice that
1526
+ // cannot be parsed.
1527
+ if (repaired == null) return t.slice(0, safe)
1528
+ end -= t.length - safe - repaired.length
1529
+ t = t.slice(0, safe) + repaired
1530
+ }
1531
+ return t.slice(0, Math.max(0, safeMarkdownCut(t, end)))
1532
+ }
1533
+
1534
+ /**
1535
+ * Remove the opening delimiter of the entity that begins at the head of
1536
+ * `rest`, preserving any leading newline so line structure survives. Returns
1537
+ * `null` when the head is not an entity opener.
1538
+ */
1539
+ function dropLeadingOpener(rest: string): string | null {
1540
+ const fence = FENCE_OPENER_AT_HEAD.exec(rest)
1541
+ if (fence != null) return fence[1] + rest.slice(fence[0].length)
1542
+ const inline = INLINE_OPENER_AT_HEAD.exec(rest)
1543
+ if (inline != null) return rest.slice(inline[0].length)
1544
+ const row = TABLE_ROW_OPENER_AT_HEAD.exec(rest)
1545
+ if (row != null) return row[1] + rest.slice(row[0].length)
1546
+ return null
1547
+ }
1548
+
1339
1549
  function backOffOpenInline(text: string, cut: number): number {
1340
1550
  if (cut <= 0 || cut >= text.length) return cut
1341
1551
  let earliest = cut
@@ -53,6 +53,7 @@ import {
53
53
  AGENT_LIVE_POLL_INTERVAL_MS,
54
54
  } from './boot-probes.js'
55
55
  import { escapeMarkdown, stackCardLines } from '../card-format.js'
56
+ import { NESTED_PREFIX } from '../status-no-truncate.js'
56
57
  import {
57
58
  loadCache as loadBootIssueCache,
58
59
  diffProbes as diffBootProbes,
@@ -540,7 +541,11 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
540
541
  const tailCmd = process.env.SWITCHROOM_RUNTIME === 'docker'
541
542
  ? `docker logs --tail 100 switchroom-${agentSlug}`
542
543
  : `journalctl --user -u switchroom-${agentSlug} -n 100`
543
- degradedRows.push(` ↳ Tail logs: \`${tailCmd}\``)
544
+ // One indent idiom, repo-wide (#4115): NESTED_PREFIX, not ASCII spaces.
545
+ // Telegram left-trims a leading ASCII-whitespace run off a content line,
546
+ // so the ASCII-space indent this row used to carry rendered FLAT on a
547
+ // phone β€” the #3668 failure, live-verified 2026-07-26.
548
+ degradedRows.push(`${NESTED_PREFIX}Tail logs: \`${tailCmd}\``)
544
549
  }
545
550
 
546
551
  // Probe rows β€” only those that surfaced as degraded/fail. Healthy
@@ -569,7 +574,7 @@ export function renderBootCard(opts: RenderBootCardOpts): string {
569
574
  // user does or doesn't see across consecutive boots.
570
575
  degradedRows.push(`${dot} **${PROBE_LABELS[key]}** ${escapeMarkdown(r.detail)}`)
571
576
  if (r.nextStep) {
572
- degradedRows.push(` ↳ ${renderNextStep(r.nextStep)}`)
577
+ degradedRows.push(`${NESTED_PREFIX}${renderNextStep(r.nextStep)}`)
573
578
  }
574
579
  }
575
580
  }
@@ -145,6 +145,11 @@ export function queueFloodBlockedReply(input: FloodQueueInput): FloodQueueResult
145
145
  textSha256: sha256Hex(text),
146
146
  createdAt,
147
147
  source: FLOOD_QUEUED_SOURCE,
148
+ // W1-d (#3865): a flood-deferred record is a reply the agent ALREADY chose
149
+ // to send to this chat β€” its audience is never in question. Stamped
150
+ // explicitly rather than relying on the legacy default, so this producer
151
+ // stays correct if that default is ever revisited.
152
+ audience: 'user',
148
153
  ...(input.originChatId != null ? { originChatId: input.originChatId } : {}),
149
154
  ...(input.originThreadId != null ? { originThreadId: input.originThreadId } : {}),
150
155
  }
@@ -280,10 +280,8 @@ import { REPLY_TOOLS } from '../narrative-dedup.js'
280
280
  import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from '../narrative-flush.js'
281
281
  import { createTypingWrapper } from '../typing-wrap.js'
282
282
  import { createTurnTypingLoop } from './turn-typing-loop.js'
283
- import {
284
- createHandbackPreturnSignal,
285
- type PreTurnCardRecord,
286
- } from './handback-preturn-signal.js'
283
+ import { createHandbackPreturnSignal } from './handback-preturn-signal.js'
284
+ import { createHandbackOrphanRecovery } from './handback-orphan-recovery.js'
287
285
  import { deriveTurnId } from './derive-turn-id.js'
288
286
  import { createTypingEmitter, TYPING_REFRESH_MS } from '../typing-emitter.js'
289
287
  import { type DraftStreamHandle } from '../draft-stream.js'
@@ -654,6 +652,7 @@ import { startWebhookIngestServer } from './webhook-ingest-server.js'
654
652
  import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
655
653
 
656
654
  import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js'
655
+ import { handleRolloutStatusEdit } from './rollout-status-edit.js'
657
656
  import { handleRequestDriveApproval } from './drive-write-approval.js'
658
657
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
659
658
  import { buildDiffPreviewCard } from './diff-preview-card.js'
@@ -667,7 +666,7 @@ import {
667
666
  } from './obligation-ledger.js'
668
667
  // (#2996 P8 PR-C2: buildObligationRepresentInbound is now imported by
669
668
  // obligation-wiring.ts, where the sweep lives.)
670
- import { loadObligations, persistObligations } from './obligation-store.js'
669
+ import { loadObligations, persistObligations, removeUnmaintainedSnapshot } from './obligation-store.js'
671
670
  import { buildCapturedDeliverySnapshot, createCapturedResumeDispatcher } from './captured-answer-resume.js'
672
671
  import {
673
672
  loadStatusPins,
@@ -2804,7 +2803,7 @@ const obligationStoreFs = {
2804
2803
  writeFileSync: (p: string, d: string) => writeFileSync(p, d),
2805
2804
  renameSync: (a: string, b: string) => renameSync(a, b),
2806
2805
  existsSync: (p: string) => existsSync(p),
2807
- fsyncFileSync: fsyncPathSync, fsyncDirSync: fsyncPathSync,
2806
+ fsyncFileSync: fsyncPathSync, fsyncDirSync: fsyncPathSync, unlinkSync,
2808
2807
  }
2809
2808
  const obligationLedger = new ObligationLedger(OBLIGATION_REPRESENT_MAX, {
2810
2809
  onChange:
@@ -2822,7 +2821,7 @@ if (isGatewayMain && !STATIC && OBLIGATION_LEDGER_ENABLED) {
2822
2821
  `telegram gateway: obligation-ledger hydrated ${restored.length} open obligation(s) from ${OBLIGATION_STORE_PATH}\n`,
2823
2822
  )
2824
2823
  }
2825
- }
2824
+ } else if (isGatewayMain) removeUnmaintainedSnapshot(OBLIGATION_STORE_PATH, obligationStoreFs, `static=${STATIC} enabled=${OBLIGATION_LEDGER_ENABLED}`) // #4146: persistence is off, so a leftover snapshot must not read as "nobody is waiting" to the W1-d audience classifier (logic in obligation-store.ts)
2826
2825
  // Origin ids with an escalation send IN FLIGHT β€” prevents the 5s sweep from
2827
2826
  // firing a second concurrent send for the same obligation while the first is
2828
2827
  // still awaiting (an escalation that takes >5s, or repeated failures).
@@ -10052,7 +10051,7 @@ async function deliverCapturedProse(args: {
10052
10051
  originTurnId: string
10053
10052
  text: string
10054
10053
  /** Turn elapsed for the honest "(waited Ns)" apology clause; optional. */
10055
- turnDurationMs?: number
10054
+ turnDurationMs?: number; replyToolThrewThisTurn?: boolean // #4141: label prose recovered from a turn whose reply tool threw (see outbound-send-path.ts)
10056
10055
  }): Promise<void> {
10057
10056
  // #2996 P2: body moved VERBATIM to outbound-send-path.ts (single tested
10058
10057
  // send module). Injects the ONE `outboundDedup` instance (Amendment 1/9).
@@ -11447,28 +11446,31 @@ if (isGatewayMain) ipcServer = createIpcServer({
11447
11446
  }
11448
11447
  },
11449
11448
 
11450
- // #2726 Part 2 β€” edit the previously-posted rollout status message in place as
11451
- // later phases arrive. Fire-and-forget: an edit failure (incl. Telegram 429)
11452
- // is swallowed here and NEVER surfaced back toward the roll. hostd owns the
11453
- // debounce + 429 retry cadence; this handler is a thin edit relay.
11454
- onRolloutStatusEdit(_client: IpcClient, msg: RolloutStatusEditMessage) {
11455
- const self = process.env.SWITCHROOM_AGENT_NAME
11456
- if (self && msg.agentName !== self) {
11457
- process.stderr.write(
11458
- `telegram gateway: rollout_status_edit rejected β€” agent mismatch (${msg.agentName} != ${self})\n`,
11459
- )
11460
- return
11461
- }
11462
- const operator = loadAccess().allowFrom[0]
11463
- if (operator === undefined) {
11464
- process.stderr.write(`telegram gateway: rollout_status_edit β€” no operator chat (allowFrom empty)\n`)
11465
- return
11466
- }
11467
- void swallowingApiCall(
11468
- () =>
11469
- // allow-raw-bot-api: in-place edit of the ordinary status message.
11470
- bot.api.editMessageText(operator, msg.messageId, richMessage(msg.text), {}),
11471
- { chat_id: String(operator), verb: 'rollout-status-edit' },
11449
+ // #2726 Part 2 / #4065 β€” edit the previously-posted rollout status message in
11450
+ // place as later phases arrive, and REPLY with the outcome so hostd can tell
11451
+ // an applied edit from an edit into a deleted card (see rollout-status-edit.ts).
11452
+ // Still fire-and-forget toward the roll: hostd never blocks on the reply.
11453
+ onRolloutStatusEdit(client: IpcClient, msg: RolloutStatusEditMessage) {
11454
+ return handleRolloutStatusEdit(
11455
+ {
11456
+ selfAgentName: process.env.SWITCHROOM_AGENT_NAME,
11457
+ operatorChatId: () => loadAccess().allowFrom[0],
11458
+ editMessage: (chatId, messageId, text) =>
11459
+ robustApiCall(
11460
+ () =>
11461
+ // allow-raw-bot-api: in-place edit of the ordinary status message.
11462
+ bot.api.editMessageText(chatId, messageId, richMessage(text), {}),
11463
+ // BOTH opt-outs are load-bearing β€” see "TWO swallows" in
11464
+ // rollout-status-edit.ts. A SHED edit and a SWALLOWED benign
11465
+ // `400 message to edit not found` each resolve WITHOUT throwing,
11466
+ // and the handler reads success from the absence of a throw.
11467
+ { chat_id: String(chatId), verb: 'rollout-status-edit',
11468
+ priorityClass: 'critical', rethrowBenign400: true },
11469
+ ),
11470
+ log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
11471
+ },
11472
+ client,
11473
+ msg,
11472
11474
  )
11473
11475
  },
11474
11476
 
@@ -13743,8 +13745,6 @@ if (isGatewayMain && !STATIC && FEED_HEARTBEAT_ENABLED) {
13743
13745
  // closes. Kill switch: SWITCHROOM_HANDBACK_PRETURN=0.
13744
13746
  const HANDBACK_PRETURN_ENABLED = !STATIC && process.env.SWITCHROOM_HANDBACK_PRETURN !== '0'
13745
13747
  const HANDBACK_PRETURN_HTML = '🀝 Reading the worker’s results…'
13746
- const HANDBACK_PRETURN_ORPHAN_HTML =
13747
- '🀝 A background worker finished, but the handback never started β€” it may need a nudge.'
13748
13748
 
13749
13749
  async function openHandbackPreTurnCard(
13750
13750
  chatId: string,
@@ -13774,24 +13774,20 @@ async function openHandbackPreTurnCard(
13774
13774
  }
13775
13775
  }
13776
13776
 
13777
- function finalizeHandbackPreTurnCard(record: PreTurnCardRecord): Promise<void> {
13778
- return robustApiCall(
13779
- () =>
13780
- bot.api.editMessageText(
13781
- record.chatId,
13782
- record.activityMessageId,
13783
- richMessage(HANDBACK_PRETURN_ORPHAN_HTML),
13784
- {},
13785
- ),
13786
- {
13787
- chat_id: record.chatId,
13788
- ...(record.threadId != null ? { threadId: record.threadId } : {}),
13789
- verb: 'handback-preturn.orphan-finalize',
13790
- },
13791
- )
13792
- .then(() => undefined)
13793
- .catch(() => undefined)
13794
- }
13777
+ // Deterministic recovery for a genuinely-orphaned handback: delete the frozen
13778
+ // pre-turn card, re-inject the handback through the pending-inbound buffer, and
13779
+ // past the retry cap emit fleet-health telemetry β€” never an operator-facing
13780
+ // "needs a nudge" card. See handback-orphan-recovery.ts.
13781
+ const handbackOrphanRecovery = createHandbackOrphanRecovery({
13782
+ deleteMessage: (chatId, messageId, threadId) =>
13783
+ robustApiCall(() => bot.api.deleteMessage(chatId, messageId), {
13784
+ chat_id: chatId,
13785
+ ...(threadId != null ? { threadId } : {}),
13786
+ verb: 'handback-preturn.orphan-delete',
13787
+ }),
13788
+ pushInbound: (inbound) =>
13789
+ pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? '', inbound),
13790
+ })
13795
13791
 
13796
13792
  const handbackPreturnSignal = createHandbackPreturnSignal({
13797
13793
  chatKey: (chatId, threadId) => chatKey(chatId, threadId) as string,
@@ -13799,7 +13795,9 @@ const handbackPreturnSignal = createHandbackPreturnSignal({
13799
13795
  startTypingLoop: (chatId, threadId) => startTurnTypingLoop(chatId, threadId),
13800
13796
  stopTypingLoop: (chatId, threadId) => stopTurnTypingLoop(chatId, threadId),
13801
13797
  openCard: openHandbackPreTurnCard,
13802
- finalizeCard: finalizeHandbackPreTurnCard,
13798
+ deleteCard: handbackOrphanRecovery.deleteCard,
13799
+ reinjectHandback: handbackOrphanRecovery.reinjectHandback,
13800
+ escalateOrphan: handbackOrphanRecovery.escalateOrphan,
13803
13801
  writeCardRecord: (record) => {
13804
13802
  if (!activityCardPersistEnabled) return
13805
13803
  writeActivityCardRecord(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs, record)
@@ -0,0 +1,69 @@
1
+ /**
2
+ * handback-orphan-recovery.ts β€” the gateway-side EFFECTS for deterministic
3
+ * recovery of a genuinely-orphaned sub-agent handback.
4
+ *
5
+ * The decision ("is this an orphan, should it be re-injected, has it exhausted
6
+ * its retries") lives in `handback-preturn-signal.ts`. This module owns only the
7
+ * three side effects that decision drives, so they live in a module instead of
8
+ * inline in gateway.ts (switchroom#2996 anti-inflation ratchet β€” new logic goes
9
+ * into a seam, not into the 24k-line gateway):
10
+ *
11
+ * 1. `deleteCard` β€” DELETE (never edit) the frozen pre-turn card. The
12
+ * old behaviour rewrote it to an operator-facing string asking a human to
13
+ * manually nudge a system that must recover itself. That string is gone
14
+ * from the codebase; the card is removed outright.
15
+ * 2. `reinjectHandback` β€” push the handback back through the pending-inbound
16
+ * buffer (the same path live synthesis + boot replay use) so the idle drain
17
+ * re-delivers it and the machine re-processes it. The seam stamps the retry
18
+ * counter on `inbound.meta` before calling this, so the cap survives the
19
+ * round trip.
20
+ * 3. `escalateOrphan` β€” retries exhausted β†’ fleet-health TELEMETRY, never a
21
+ * chat card. A structured line on the gateway log that the nightly
22
+ * fleet-health sensor (`src/fleet-health/scan.ts`) reads; the
23
+ * `handback orphan escalation` marker is greppable and carries the join key.
24
+ *
25
+ * Every effect is injected, so the contract is asserted by a unit test with spy
26
+ * transports (`tests/handback-orphan-recovery.test.ts`) rather than by reading
27
+ * gateway.ts.
28
+ */
29
+
30
+ import type { InboundMessage } from './ipc-protocol.js'
31
+ import type { HandbackOrphanEscalation, PreTurnCardRecord } from './handback-preturn-signal.js'
32
+
33
+ export interface HandbackOrphanRecoveryDeps {
34
+ /** Delete a message. The gateway supplies its `robustApiCall`-wrapped
35
+ * `bot.api.deleteMessage`; rejections are swallowed here (best-effort). */
36
+ deleteMessage: (chatId: string, messageId: number, threadId: number | null) => Promise<unknown>
37
+ /** The gateway's `pendingInboundBuffer.push`, pre-bound to this agent. */
38
+ pushInbound: (inbound: InboundMessage) => void
39
+ /** Telemetry sink. Defaults to the gateway log (stderr). */
40
+ writeLog?: (line: string) => void
41
+ }
42
+
43
+ /** Structured, greppable telemetry line for an exhausted-retry orphan. Exported
44
+ * so the test asserts the exact marker + join keys the fleet-health sensor
45
+ * greps for, not just that "something was logged". */
46
+ export function formatOrphanEscalation(esc: HandbackOrphanEscalation): string {
47
+ return (
48
+ `telegram gateway: handback orphan escalation key=${esc.statusKey} ` +
49
+ `turnId=${esc.adoptTurnId} reinjects=${esc.reinjectCount} ageMs=${esc.ageMs} ` +
50
+ `β€” deterministic recovery exhausted, no operator nudge issued\n`
51
+ )
52
+ }
53
+
54
+ export function createHandbackOrphanRecovery(deps: HandbackOrphanRecoveryDeps): {
55
+ deleteCard: (record: PreTurnCardRecord) => Promise<void>
56
+ reinjectHandback: (inbound: InboundMessage) => void
57
+ escalateOrphan: (escalation: HandbackOrphanEscalation) => void
58
+ } {
59
+ const writeLog = deps.writeLog ?? ((line: string) => void process.stderr.write(line))
60
+ return {
61
+ deleteCard: (record) =>
62
+ deps
63
+ .deleteMessage(record.chatId, record.activityMessageId, record.threadId)
64
+ .then(() => undefined)
65
+ .catch(() => undefined),
66
+ reinjectHandback: (inbound) => deps.pushInbound(inbound),
67
+ escalateOrphan: (esc) => writeLog(formatOrphanEscalation(esc)),
68
+ }
69
+ }