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
@@ -69,6 +69,11 @@ import {
69
69
  isCapturedProseDeliveryEnabledEnv,
70
70
  isGatewayHeartbeatFresh,
71
71
  } from './silent-end-scan.mjs'
72
+ import {
73
+ decideCaptureAudience,
74
+ resolveOpenObligation,
75
+ AUDIENCE_INTERNAL,
76
+ } from './audience-classify.mjs'
72
77
 
73
78
  // MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
74
79
  // (this hook is a standalone .mjs and can't import the TS module).
@@ -114,6 +119,16 @@ function buildNextState(base, decision, retryCount) {
114
119
  } else {
115
120
  delete next.pendingText
116
121
  }
122
+ // #4141: carry THIS turn's reply-throw signal through to the captured-prose
123
+ // bridge, which is the delivering machine on the ELECTED path and has no
124
+ // transcript of its own. Same stale-carryover discipline as `pendingText` /
125
+ // `turnId`: explicitly DELETED when this turn's scan saw no throw, so a
126
+ // spread of a prior turn's file can never mislabel a clean turn.
127
+ if (decision.replyToolThrewThisTurn === true) {
128
+ next.replyToolThrewThisTurn = true
129
+ } else {
130
+ delete next.replyToolThrewThisTurn
131
+ }
117
132
  return next
118
133
  }
119
134
 
@@ -156,13 +171,64 @@ function outboxAlreadyDelivered(outboxDir, nonce) {
156
171
  return false
157
172
  }
158
173
 
174
+ /**
175
+ * W1-d (#3865): classify WHO the captured prose is for, at capture time — the
176
+ * only point in the pipeline that can still see the turn.
177
+ *
178
+ * The obligation snapshot is read here (impure) and the verdict is computed by
179
+ * the shared pure classifier, so the hook and the gateway run the identical
180
+ * predicate. Any failure resolves to `'user'` via `resolveOpenObligation`'s
181
+ * `'unknown'` → the record delivers exactly as it does today.
182
+ *
183
+ * @param {string} stateDir
184
+ * @param {{ chatId: string|null, originChatId?: string|null, replyToolThrewThisTurn?: boolean }} capture
185
+ * @returns {'user' | 'internal'}
186
+ */
187
+ function classifyCaptureAudience(stateDir, capture) {
188
+ let snapshotRaw = null
189
+ try {
190
+ const p = join(stateDir, 'obligations.json')
191
+ if (existsSync(p)) snapshotRaw = readFileSync(p, 'utf8')
192
+ } catch {
193
+ /* unreadable ⇒ null ⇒ 'unknown' ⇒ 'user' (fail-safe toward delivering) */
194
+ }
195
+ // #4146: with the ledger disabled, obligations are going UNTRACKED and any
196
+ // file on disk is a leftover, not a fact. Distrust it outright rather than
197
+ // read a stale empty set as positive proof that nobody is waiting — that is
198
+ // the one configuration in which the asymmetric default would invert. (The
199
+ // gateway also unlinks the snapshot at boot in this mode; this is the
200
+ // reader-side half, covering the window before it does.)
201
+ // STATIC is the second persistence-off mode (`gateway.ts:1343` gates the same
202
+ // `onChange` wiring on it at `:2810`), so it must distrust the file for the
203
+ // same reason. Writer-side cleanup already covers it at boot; this leg covers
204
+ // the window the writer cannot — an unlink that failed (EACCES) or a gateway
205
+ // that has not restarted since the mode changed. Honest caveat:
206
+ // `TELEGRAM_ACCESS_MODE` is supplied by the host, not by this repo, so if it
207
+ // is not exported into the hook's environment this leg degrades to a no-op and
208
+ // the writer-side unlink remains the cover. It can only ever move the verdict
209
+ // toward DELIVER, so a degraded read is never worse than today.
210
+ const snapshotTrusted =
211
+ process.env.SWITCHROOM_OBLIGATION_LEDGER !== '0' &&
212
+ process.env.TELEGRAM_ACCESS_MODE !== 'static'
213
+ return decideCaptureAudience({
214
+ replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
215
+ openInboundObligation: resolveOpenObligation({
216
+ snapshotRaw,
217
+ snapshotTrusted,
218
+ // Same chat the sweep will route to: the envelope chat when present,
219
+ // else this session's origin chat (`resolveOutboxChat`'s F2 fallback).
220
+ chatId: capture.chatId ?? capture.originChatId ?? null,
221
+ }),
222
+ })
223
+ }
224
+
159
225
  /**
160
226
  * Write the durable outbox record for a captured undelivered final answer
161
227
  * (atomic tmp+rename), mirroring `writeOutboxRecordAtomic` in `../outbox.ts`.
162
228
  * The gateway heartbeat sweep is the single deliverer. Best-effort; never
163
229
  * throws. Returns true when a record exists on disk for the nonce afterward.
164
230
  */
165
- function writeOutboxRecord(stateDir, capture) {
231
+ function writeOutboxRecord(stateDir, capture, audience) {
166
232
  const outboxDir = join(stateDir, 'outbox')
167
233
  try {
168
234
  mkdirSync(outboxDir, { recursive: true })
@@ -187,6 +253,18 @@ function writeOutboxRecord(stateDir, capture) {
187
253
  // journal alone. Driven by the SAME boolean that gates capture-vs-election
188
254
  // in main() — fix and telemetry cannot drift.
189
255
  replyAlreadyDeliveredThisTurn: capture.replyAlreadyDeliveredThisTurn === true,
256
+ // W1-d (#3865): WHO this text is for. Always stamped explicitly (never
257
+ // left implicit) so a post-change record is self-describing and the
258
+ // sweep's gate never has to infer. `'user'` is byte-for-byte the
259
+ // pre-change behaviour.
260
+ audience: audience === AUDIENCE_INTERNAL ? AUDIENCE_INTERNAL : 'user',
261
+ // W1-d follow-up (#4141): the RAW structural signal, persisted alongside
262
+ // the verdict it fed. `audience` collapses it with the obligation state
263
+ // and loses it; the sweep needs it on its own to decide provenance
264
+ // framing for the `'user'` records the audience gate deliberately does
265
+ // not catch (the foreground case). Stamped here because this is the last
266
+ // point in the pipeline that can still see the transcript.
267
+ replyToolThrewThisTurn: capture.replyToolThrewThisTurn === true,
190
268
  }
191
269
  const tmpPath = join(outboxDir, `.${capture.turnNonce}.${process.pid}.tmp`)
192
270
  writeFileSync(tmpPath, JSON.stringify(record), 'utf8')
@@ -267,11 +345,16 @@ function main() {
267
345
  `deferring to single-writer election (#3510)\n`,
268
346
  )
269
347
  } else {
270
- writeOutboxRecord(stateDir, capture)
348
+ const audience = classifyCaptureAudience(stateDir, capture)
349
+ writeOutboxRecord(stateDir, capture, audience)
271
350
  process.stderr.write(
272
351
  `[silent-end-interrupt] captured undelivered final answer to outbox ` +
273
352
  `(nonce=${capture.turnNonce} source=${capture.source} chars=${capture.text.length} ` +
274
- `replyAlreadyDeliveredThisTurn=false) sweep will deliver; allowing stop\n`,
353
+ `replyAlreadyDeliveredThisTurn=false audience=${audience} ` +
354
+ `replyToolThrewThisTurn=${capture.replyToolThrewThisTurn === true}) — ` +
355
+ `${audience === AUDIENCE_INTERNAL
356
+ ? 'internal prose: journaled for inspection, sweep will NOT deliver'
357
+ : 'sweep will deliver'}; allowing stop\n`,
275
358
  )
276
359
  process.exit(0)
277
360
  }
@@ -248,9 +248,18 @@ function buildTurnId(chatId, threadId, messageId) {
248
248
  * reply tool (Option A transcript-prose bridge). Only populated when it
249
249
  * clears the substance floor, so the gateway never re-delivers a short
250
250
  * trailing pleasantry. Omitted entirely otherwise.
251
+ * @param {boolean} [replyToolThrewThisTurn] W1-d follow-up (#4141): one of this
252
+ * turn's reply-tool calls came back `is_error: true`. Carried on the BLOCK
253
+ * result so the single-writer election can persist it onto the silent-end
254
+ * state file — the captured-prose bridge (the machine that actually delivers
255
+ * on the ELECTED path, where no outbox record is ever written) has no other
256
+ * way to learn it. A raw SIGNAL, never a verdict: downstream it only ever
257
+ * adds a provenance banner. Omitted rather than `false` when absent, so a
258
+ * spread of a prior state file cannot be read as positive evidence.
251
259
  */
252
- function buildBlockResult(envelope, reason, pendingText, hasTrailingProse) {
260
+ function buildBlockResult(envelope, reason, pendingText, hasTrailingProse, replyToolThrewThisTurn) {
253
261
  const block = { decided: 'block', reason }
262
+ if (replyToolThrewThisTurn === true) block.replyToolThrewThisTurn = true
254
263
  // Single-writer election input (#duplicate-message fix): does ANY
255
264
  // non-empty, non-silent trailing text block exist after the last
256
265
  // delivery event? The zero-reply election allows only when this is
@@ -381,6 +390,15 @@ export function scanTurnForFinalReply(jsonl) {
381
390
  // whether emitted as plain text or as a reply-tool payload) or
382
391
  // plain undelivered text.
383
392
  const blocks = []
393
+ // W1-d follow-up (#4141): the same reply-throw signal `scanForOutboxCapture`
394
+ // already derives, computed here too because the ELECTED path never reaches
395
+ // that scanner's capture branch (a thrown-but-qualifying reply sets
396
+ // `replyAlreadyDeliveredThisTurn`, so the hook defers to the election and
397
+ // writes NO outbox record). Collected as two id sets whose intersection is
398
+ // the signal. Read-only: nothing below branches on it, so no verdict of this
399
+ // scanner changes.
400
+ const replyToolUseIds = new Set()
401
+ const erroredToolUseIds = new Set()
384
402
  for (let i = startIdx + 1; i < lines.length; i++) {
385
403
  const line = lines[i]
386
404
  if (!line || line[0] !== '{') continue
@@ -390,6 +408,20 @@ export function scanTurnForFinalReply(jsonl) {
390
408
  // be in a separate transcript file, but `isSidechain:true` is the
391
409
  // documented marker if they leak).
392
410
  if (obj?.isSidechain === true) continue
411
+ if (obj?.type === 'user') {
412
+ // Tool RESULT lines: `user`-type, content array carrying
413
+ // `{type:'tool_result', tool_use_id, is_error}`. Same shape read by
414
+ // `scanForOutboxCapture` (#3865).
415
+ const rc = obj?.message?.content
416
+ if (!Array.isArray(rc)) continue
417
+ for (const r of rc) {
418
+ if (r?.type !== 'tool_result') continue
419
+ if (r?.is_error !== true) continue
420
+ if (typeof r.tool_use_id !== 'string') continue
421
+ erroredToolUseIds.add(r.tool_use_id)
422
+ }
423
+ continue
424
+ }
393
425
  if (obj?.type !== 'assistant') continue
394
426
  const content = obj?.message?.content
395
427
  if (!Array.isArray(content)) continue
@@ -449,6 +481,9 @@ export function scanTurnForFinalReply(jsonl) {
449
481
  }
450
482
  continue
451
483
  }
484
+ // #4141: EVERY reply-tool call's id, qualifying or not — an interim ack
485
+ // that threw is still a reply-tool throw.
486
+ if (typeof c.id === 'string') replyToolUseIds.add(c.id)
452
487
  const input = c.input ?? {}
453
488
  const text = String(input.text ?? '')
454
489
  // Silent-marker carve-out: the operator explicitly signaled
@@ -555,6 +590,10 @@ export function scanTurnForFinalReply(jsonl) {
555
590
  // delivers the joined prose instead of dropping. A pure narration run still
556
591
  // yields `undefined` (the #3228 Finding 2 guard, now structural).
557
592
  const zeroReplyPendingText = backstopText
593
+ // #4141 — one of this turn's reply-tool calls errored. Derived here, consumed
594
+ // only by `buildBlockResult` below (and thence the elected state file); it
595
+ // gates nothing in this scanner.
596
+ const replyToolThrewThisTurn = [...replyToolUseIds].some((id) => erroredToolUseIds.has(id))
558
597
 
559
598
  if (lastAllowBlockIdx === -1) {
560
599
  // No qualifying delivery/silence event anywhere in the turn.
@@ -567,7 +606,9 @@ export function scanTurnForFinalReply(jsonl) {
567
606
  if (envelope.source === 'cron') {
568
607
  return { decided: 'allow', reason: 'cron-source' }
569
608
  }
570
- return buildBlockResult(envelope, 'no-final-reply', zeroReplyPendingText, hasTrailingProse)
609
+ return buildBlockResult(
610
+ envelope, 'no-final-reply', zeroReplyPendingText, hasTrailingProse, replyToolThrewThisTurn,
611
+ )
571
612
  }
572
613
 
573
614
  if (sawUndeliveredTextAfterAllow) {
@@ -576,7 +617,9 @@ export function scanTurnForFinalReply(jsonl) {
576
617
  // sent through a delivery tool. This is the "at least once" bug:
577
618
  // an early ack (or any qualifying reply) must not amnesty
578
619
  // everything written afterward.
579
- return buildBlockResult(envelope, 'trailing-text-after-reply', pendingText, hasTrailingProse)
620
+ return buildBlockResult(
621
+ envelope, 'trailing-text-after-reply', pendingText, hasTrailingProse, replyToolThrewThisTurn,
622
+ )
580
623
  }
581
624
 
582
625
  return { decided: 'allow', reason: lastAllowReason }
@@ -917,7 +960,7 @@ function resolveSessionOriginChat(lines) {
917
960
  *
918
961
  * @param {string} jsonl
919
962
  * @param {number} [now]
920
- * @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string, replyAlreadyDeliveredThisTurn: boolean, deliveredReplySha256: string|null }}
963
+ * @returns {{ capture: false, reason: string } | { capture: true, text: string, turnNonce: string, chatId: string|null, threadId: number|null, source: string, anchorContent: string, replyAlreadyDeliveredThisTurn: boolean, deliveredReplySha256: string|null, replyToolThrewThisTurn: boolean }}
921
964
  */
922
965
  export function scanForOutboxCapture(jsonl, now = Date.now()) {
923
966
  const lines = jsonl.split('\n')
@@ -925,12 +968,34 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
925
968
  const { envelope } = anchor
926
969
 
927
970
  const blocks = []
971
+ // W1-d (#3865): ids of reply-tool `tool_use` blocks seen this turn, and the
972
+ // ids whose `tool_result` came back `is_error: true`. Their intersection is
973
+ // the "did the reply tool throw this turn" signal.
974
+ const replyToolUseIds = new Set()
975
+ const erroredToolUseIds = new Set()
928
976
  for (let i = anchor.startIdx + 1; i < lines.length; i++) {
929
977
  const line = lines[i]
930
978
  if (!line || line[0] !== '{') continue
931
979
  let obj
932
980
  try { obj = JSON.parse(line) } catch { continue }
933
981
  if (obj?.isSidechain === true) continue
982
+ // W1-d (#3865): tool RESULT lines are how a reply-tool THROW becomes
983
+ // visible. Claude Code writes the result of a `tool_use` as a `user`-type
984
+ // line whose content array carries `{type:'tool_result', tool_use_id,
985
+ // is_error}`. Nothing in this scanner read those before, which is exactly
986
+ // why an errored reply was indistinguishable from a delivered one — the
987
+ // transcript shape is otherwise identical.
988
+ if (obj?.type === 'user') {
989
+ const rc = obj?.message?.content
990
+ if (!Array.isArray(rc)) continue
991
+ for (const r of rc) {
992
+ if (r?.type !== 'tool_result') continue
993
+ if (r?.is_error !== true) continue
994
+ if (typeof r.tool_use_id !== 'string') continue
995
+ erroredToolUseIds.add(r.tool_use_id)
996
+ }
997
+ continue
998
+ }
934
999
  if (obj?.type !== 'assistant') continue
935
1000
  const content = obj?.message?.content
936
1001
  if (!Array.isArray(content)) continue
@@ -971,6 +1036,12 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
971
1036
  }
972
1037
  continue
973
1038
  }
1039
+ // W1-d (#3865): remember EVERY reply-tool call's id, not just the
1040
+ // qualifying-final-answer ones. An interim ack that threw is still a
1041
+ // reply-tool throw, and the #3861 shape (reply raises, agent then writes
1042
+ // trailing working notes) does not require the errored call to have been
1043
+ // a qualifying final answer.
1044
+ if (typeof c.id === 'string') replyToolUseIds.add(c.id)
974
1045
  const input = c.input ?? {}
975
1046
  const text = String(input.text ?? '')
976
1047
  if (SILENT_MARKER_RE.test(text.trim()) || endsWithSilentMarker(text)) {
@@ -1081,5 +1152,10 @@ export function scanForOutboxCapture(jsonl, now = Date.now()) {
1081
1152
  originThreadId: origin.threadId,
1082
1153
  replyAlreadyDeliveredThisTurn,
1083
1154
  deliveredReplySha256,
1155
+ // W1-d (#3865): one of this turn's reply-tool calls came back an error.
1156
+ // Reported as a raw SIGNAL, not a verdict — `decideCaptureAudience` in
1157
+ // `audience-classify.mjs` combines it with the obligation state (which
1158
+ // this pure transcript scanner cannot see) to decide the audience.
1159
+ replyToolThrewThisTurn: [...replyToolUseIds].some((id) => erroredToolUseIds.has(id)),
1084
1160
  }
1085
1161
  }
@@ -65,6 +65,19 @@ import {
65
65
  } from 'node:fs'
66
66
  import { join } from 'node:path'
67
67
  import { homedir } from 'node:os'
68
+ // W1-d (#3865). The audience vocabulary and its resolution rule live in ONE
69
+ // shared pure module because the unbundled Stop hooks (which stamp it) and this
70
+ // bundled gateway code (which persists and enforces it) must agree exactly. A
71
+ // hand-synced second copy here would be a leak waiting on a drift.
72
+ import type { Audience } from './hooks/audience-classify.mjs'
73
+ // #4141: the provenance-framing rule lives in the same shared pure module, for
74
+ // the same reason — the decision must be one implementation, not two.
75
+ import {
76
+ applyReplyThrowFraming,
77
+ shouldFrameReplyThrow,
78
+ } from './hooks/audience-classify.mjs'
79
+
80
+ export type { Audience }
68
81
 
69
82
  /** One captured, not-yet-delivered final message for a single main-session turn. */
70
83
  export interface OutboxRecord {
@@ -113,6 +126,42 @@ export interface OutboxRecord {
113
126
  * or in a sweep journal entry — is direct evidence of a regression.
114
127
  */
115
128
  replyAlreadyDeliveredThisTurn?: boolean
129
+ /**
130
+ * W1-d (#3865): WHO this text is addressed to, decided at capture time by
131
+ * `hooks/audience-classify.mjs` (`decideCaptureAudience`) — the only point in
132
+ * the pipeline that can still see the turn that produced it.
133
+ *
134
+ * `'internal'` means the text is the agent's own working notes (the #3861
135
+ * shape: the reply tool threw, nobody was waiting, and the structurally-
136
+ * selected terminal prose run is orchestration narration rather than an
137
+ * answer). Such a record is journaled and inspectable but MUST NEVER reach a
138
+ * chat — the sweep drops it at entry selection.
139
+ *
140
+ * OPTIONAL for legacy reasons: every record written before this field
141
+ * existed has no `audience`, and `resolveRecordAudience` maps missing (and
142
+ * any non-`'internal'` value) to `'user'`. Suppression requires an
143
+ * affirmative tag; absence is never evidence.
144
+ */
145
+ audience?: Audience
146
+ /**
147
+ * W1-d follow-up (#4141): did one of this turn's reply-tool calls come back
148
+ * `is_error: true`? Stamped at capture by `hooks/silent-end-scan.mjs` (which
149
+ * reads the `tool_result` blocks) and persisted here so the SWEEP — hours
150
+ * later, in another process, with the transcript long gone — can still state
151
+ * this text's provenance.
152
+ *
153
+ * This is the raw structural SIGNAL, not a verdict. `audience` already
154
+ * consumed it once (combined with the obligation state) to decide
155
+ * suppression; the sweep consumes it again, on its own, to decide FRAMING for
156
+ * the records suppression deliberately does NOT catch — the foreground case,
157
+ * where the reply threw but someone is provably still waiting, so the record
158
+ * is `'user'` and MUST deliver.
159
+ *
160
+ * OPTIONAL: absent on every pre-#4141 record and on gateway-authored records
161
+ * (`gateway/flood-reply-queue.ts`), both of which deliver unframed. Only an
162
+ * exact boolean `true` changes anything — see `shouldFrameReplyThrow`.
163
+ */
164
+ replyToolThrewThisTurn?: boolean
116
165
  }
117
166
 
118
167
  /** One line of the delivered-keys journal (`outbox/delivered.jsonl`). */
@@ -131,6 +180,30 @@ export interface DeliveredEntry {
131
180
  deliverySource?: 'sweep' | 'reply-tool' | 'flush'
132
181
  /** #3510 instrumentation: see `OutboxRecord.replyAlreadyDeliveredThisTurn`. */
133
182
  replyAlreadyDeliveredThisTurn?: boolean
183
+ /** W1-d (#3865): the record's audience, carried onto the journal line. */
184
+ audience?: Audience
185
+ /**
186
+ * W1-d (#3865): this journal line is a TERMINAL SUPPRESSION, not a delivery.
187
+ * The sweep withheld an `'internal'` record from the chat and wrote this line
188
+ * so the nonce is terminally claimed (a second tick sends nothing) and the
189
+ * decision is durably auditable.
190
+ *
191
+ * This is what keeps suppression honest: the outcome is recorded as an
192
+ * explicit, named state rather than as an absence. `tgMessageId` is absent on
193
+ * these lines because nothing was sent.
194
+ */
195
+ suppressedAudience?: Audience
196
+ /**
197
+ * W1-d follow-up (#4141): this delivery carried the reply-throw provenance
198
+ * banner (`REPLY_THROW_PROVENANCE_NOTICE`) in front of the captured prose.
199
+ *
200
+ * Durable and terminal, for the same reason `suppressedAudience` is: framing
201
+ * changes what a human sees, so the outcome is recorded as an explicit named
202
+ * state on the journal line rather than being inferable only from a log line
203
+ * that may have rotated away. `tgMessageId` IS present on these lines — a
204
+ * framed delivery is a delivery.
205
+ */
206
+ framedProvenance?: 'reply-throw'
134
207
  }
135
208
 
136
209
  export function sha256Hex(s: string): string {
@@ -439,6 +512,13 @@ export interface OutboxSweepDecision {
439
512
  action: OutboxSweepAction
440
513
  /** The text to deliver (with any "(delayed)"/"(from background task)" prefix). */
441
514
  text?: string
515
+ /**
516
+ * W1-d follow-up (#4141): `text` carries the reply-throw provenance banner.
517
+ * Surfaced on the decision (rather than left implicit in a string compare) so
518
+ * the caller can journal the outcome and emit telemetry without re-deriving
519
+ * the rule.
520
+ */
521
+ framedProvenance?: 'reply-throw'
442
522
  }
443
523
 
444
524
  /**
@@ -456,7 +536,10 @@ export interface OutboxSweepDecision {
456
536
  * send-delayed — older than max-age; deliver with a "(delayed)" prefix, never drop.
457
537
  */
458
538
  export function decideOutboxSweep(input: {
459
- record: Pick<OutboxRecord, 'turnNonce' | 'text' | 'createdAt'>
539
+ record: Pick<
540
+ OutboxRecord,
541
+ 'turnNonce' | 'text' | 'createdAt' | 'replyToolThrewThisTurn'
542
+ >
460
543
  now: number
461
544
  deliveredNonces: Set<string>
462
545
  textAlreadyDelivered: boolean
@@ -476,6 +559,13 @@ export function decideOutboxSweep(input: {
476
559
  * `normalizeOutboundBody` (it sends via `bot.api.sendMessage` directly).
477
560
  */
478
561
  shownLedgerHit?: boolean
562
+ /**
563
+ * W1-d follow-up (#4141) kill switch for the reply-throw provenance banner.
564
+ * `false` restores the pre-change body byte-for-byte; the default is ON.
565
+ * Passed in rather than read from env here so this core stays pure and the
566
+ * revert-check can flip it in-process.
567
+ */
568
+ provenanceFraming?: boolean
479
569
  }): OutboxSweepDecision {
480
570
  const {
481
571
  record,
@@ -487,6 +577,7 @@ export function decideOutboxSweep(input: {
487
577
  quietMs = OUTBOX_QUIET_MS,
488
578
  maxAgeMs = OUTBOX_MAX_AGE_MS,
489
579
  shownLedgerHit = false,
580
+ provenanceFraming = true,
490
581
  } = input
491
582
  if (deliveredNonces.has(record.turnNonce)) return { action: 'skip-journaled' }
492
583
  if (shownLedgerHit) return { action: 'skip-ephemeral-shown' }
@@ -496,7 +587,17 @@ export function decideOutboxSweep(input: {
496
587
  if (!routable) return { action: 'skip-unroutable' }
497
588
  const delayed = age > maxAgeMs
498
589
  const prefix = (delayed ? '(delayed) ' : '') + routePrefix
499
- return { action: delayed ? 'send-delayed' : 'send', text: prefix + record.text }
590
+ // #4141: the banner sits BETWEEN the delivery prefixes and the body — the
591
+ // prefixes describe the DELIVERY ("(delayed)", "(from background task)"),
592
+ // the banner describes the TEXT. It is additive only: it can never turn a
593
+ // send into a skip, so this branch cannot manufacture silence.
594
+ const framed = shouldFrameReplyThrow(record, { frameEnabled: provenanceFraming })
595
+ const body = framed ? applyReplyThrowFraming(record.text) : record.text
596
+ return {
597
+ action: delayed ? 'send-delayed' : 'send',
598
+ text: prefix + body,
599
+ ...(framed && body !== record.text ? { framedProvenance: 'reply-throw' as const } : {}),
600
+ }
500
601
  }
501
602
 
502
603
  export interface ResolvedChat {
@@ -28,7 +28,11 @@
28
28
 
29
29
  import { parse } from "./parse.js";
30
30
  import { renderSafe, type RenderResult } from "./render.js";
31
- import { RICH_MESSAGE_MAX_CHARS, splitMarkdownChunks } from "../format.js";
31
+ import {
32
+ PLAIN_TEXT_MAX_CHARS,
33
+ RICH_MESSAGE_MAX_CHARS,
34
+ splitMarkdownChunks,
35
+ } from "../format.js";
32
36
 
33
37
  /**
34
38
  * The legacy plain-text `sendMessage` / `editMessageText` wire cap (4096
@@ -38,8 +42,12 @@ import { RICH_MESSAGE_MAX_CHARS, splitMarkdownChunks } from "../format.js";
38
42
  * the plain `sendMessage` endpoint (see stream-controller `doSend`/`doEdit`).
39
43
  * A degraded plain body larger than this is rejected by Telegram with
40
44
  * `message is too long`, so `renderOutboundChunks` caps plain pieces here.
45
+ *
46
+ * Defined in `format.ts` (#4043) so the parse-reject plain FALLBACKS in the
47
+ * send path can share the one constant without importing the renderer;
48
+ * re-exported here for this module's existing importers.
41
49
  */
42
- export const PLAIN_TEXT_MAX_CHARS = 4096;
50
+ export { PLAIN_TEXT_MAX_CHARS };
43
51
 
44
52
  /** Parse the `SWITCHROOM_RICH_RENDER` kill-switch value. Default ON; disabled
45
53
  * only by an explicit falsey/off token (`0`/`false`/`off`/`no`,
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Surgical quote-reply parameters (`reply_parameters.quote`).
3
+ *
4
+ * The `reply` / `stream_reply` tools document a `quote_text` argument: quote a
5
+ * specific fragment of the message being replied to, so the reply visibly
6
+ * anchors on that fragment instead of the whole message.
7
+ *
8
+ * WIRE SHAPE — the bug this module exists to make unrepeatable
9
+ * ------------------------------------------------------------
10
+ * Bot API `ReplyParameters` (https://core.telegram.org/bots/api#replyparameters):
11
+ *
12
+ * quote String Quoted part of the message to be replied to;
13
+ * 0-1024 characters after entities parsing.
14
+ * quote_parse_mode String Mode for parsing entities in the quote.
15
+ * quote_entities Array Entities in the quote.
16
+ * quote_position Integer Position of the quote in the original message in
17
+ * UTF-16 code units.
18
+ *
19
+ * `quote` is a **String** and `quote_position` is a **separate sibling
20
+ * Integer**. Both send sites used to emit `quote: { text, position: 0 }` — an
21
+ * object — so Telegram rejected every quoted reply with
22
+ * `400 Bad Request: field "quote" must be of type String`, the reply threw, and
23
+ * the agent re-attempted a payload that could never be accepted. A fleet sweep
24
+ * of `tg-post method=sendRichMessage … status=err` lines found 378 of 384 total
25
+ * send failures were this one error (agent `overlord`, 2026-07-29 → 07-30, in
26
+ * retry bursts). The feature had never worked.
27
+ *
28
+ * DELIBERATE OMISSIONS
29
+ * --------------------
30
+ * - **No `quote_position`.** Telegram locates the quote text on its own; a
31
+ * fabricated `0` is what encouraged the object shape in the first place, and
32
+ * neither send site has a real position source (the model supplies text, not
33
+ * an offset). A wrong position is worse than none: it either mislocates the
34
+ * highlight or 400s.
35
+ * - **No `quote_parse_mode` / `quote_entities`, and therefore NO HTML
36
+ * escaping.** With no parse mode the quote is matched as literal text, which
37
+ * is exactly what `quote_text` carries (plain text the model copied out of
38
+ * the user's message). Escaping it (`&` → `&amp;`) would make the string stop
39
+ * being an exact substring of the original and guarantee a rejection.
40
+ *
41
+ * REJECTION HANDLING
42
+ * ------------------
43
+ * The quote must be an exact substring of the target message; Telegram 400s
44
+ * when it isn't found. That is a *quote* failure, not an answer failure — the
45
+ * reply body is still correct and must still be delivered. {@link
46
+ * isQuoteRejectionError} classifies it and {@link dropQuoteFromSendOpts}
47
+ * produces the same options with the quote removed, so callers can retry once
48
+ * and land the reply as an ordinary (unquoted) reply.
49
+ */
50
+
51
+ import { GrammyError } from 'grammy'
52
+
53
+ import { isHtmlParseRejectError, isMessageTooLongError } from './retry-api-call.js'
54
+
55
+ /** Bot API cap: `quote` is 0-1024 characters after entities parsing. */
56
+ export const QUOTE_MAX_CHARS = 1024
57
+
58
+ /**
59
+ * Normalize a caller-supplied `quote_text` into the string Telegram accepts, or
60
+ * `null` when there is nothing worth quoting.
61
+ *
62
+ * - empty / whitespace-only ⇒ `null` (an empty quote highlights nothing and is
63
+ * a guaranteed rejection).
64
+ * - longer than {@link QUOTE_MAX_CHARS} ⇒ truncated. A PREFIX of an exact
65
+ * substring is still an exact substring, so truncation still matches the
66
+ * original message, whereas an over-length quote is a certain 400. The cut
67
+ * never splits a surrogate pair (a lone surrogate is not valid UTF-8 on the
68
+ * wire).
69
+ * - otherwise verbatim — never escaped, never trimmed (leading/trailing spaces
70
+ * are part of the substring the user may have selected).
71
+ */
72
+ export function normalizeQuoteText(quoteText: string | null | undefined): string | null {
73
+ if (typeof quoteText !== 'string') return null
74
+ if (quoteText.trim().length === 0) return null
75
+ if (quoteText.length <= QUOTE_MAX_CHARS) return quoteText
76
+ let end = QUOTE_MAX_CHARS
77
+ const code = quoteText.charCodeAt(end - 1)
78
+ // High surrogate at the cut boundary ⇒ drop it rather than emit half a pair.
79
+ if (code >= 0xd800 && code <= 0xdbff) end -= 1
80
+ return quoteText.slice(0, end)
81
+ }
82
+
83
+ /**
84
+ * Build the `reply_parameters` object for a reply to `messageId`, optionally
85
+ * carrying a surgical quote.
86
+ *
87
+ * This is the ONLY place the `quote` field is constructed. Both send sites (the
88
+ * `reply` chunk loop in `gateway/outbound-send-path.ts` and the `stream_reply`
89
+ * controller in `stream-controller.ts`) call it, so the wire shape cannot drift
90
+ * apart again.
91
+ */
92
+ export function buildReplyParameters(
93
+ messageId: number,
94
+ quoteText?: string | null,
95
+ ): { message_id: number; quote?: string } {
96
+ const quote = normalizeQuoteText(quoteText)
97
+ return { message_id: messageId, ...(quote != null ? { quote } : {}) }
98
+ }
99
+
100
+ /**
101
+ * True when Telegram rejected the send because of the QUOTE, not the body.
102
+ *
103
+ * Covers the "quote isn't in the original message" family (the documented
104
+ * failure mode) and any other 400 naming the quote — matched on the word rather
105
+ * than one exact phrase, because the Bot API's wording for this class has
106
+ * changed before (`QUOTE_TEXT_INVALID` vs the prose form) and a missed match
107
+ * costs the whole answer.
108
+ *
109
+ * A BODY error must never be claimed here, though, and the word alone is not
110
+ * enough to tell them apart: `<blockquote>` in a rich body produces
111
+ * `can't parse entities: Can't find end tag corresponding to start tag
112
+ * "blockquote"` / `Unsupported start tag "blockquote"` — 400s that contain the
113
+ * substring "quote" but are PARSE failures. Claiming one would drop the quote
114
+ * and resend the same unparseable body, hit the same 400, and throw straight
115
+ * out of the caller's fallback ladder — losing the answer that the plain-text
116
+ * rescue would otherwise have delivered. So defer to the body classifiers
117
+ * first, exactly as `isHtmlParseRejectError` defers to `isMessageTooLongError`
118
+ * (retry-api-call.ts) for the same reason. No false negative results: the real
119
+ * quote rejections (`QUOTE_TEXT_INVALID`, "message quote not found") contain
120
+ * neither a parse phrase nor a length phrase.
121
+ */
122
+ export function isQuoteRejectionError(err: unknown): boolean {
123
+ if (!(err instanceof GrammyError) || err.error_code !== 400) return false
124
+ if (isHtmlParseRejectError(err) || isMessageTooLongError(err)) return false
125
+ return (err.description || '').toLowerCase().includes('quote')
126
+ }
127
+
128
+ /** True when `opts` carries a `reply_parameters.quote`. Structural on purpose:
129
+ * the reply path builds untyped `Record<string, unknown>` send options and the
130
+ * stream path a typed `StreamSendOpts`, and both must be checkable. */
131
+ export function sendOptsHaveQuote(opts: object): boolean {
132
+ const rp = (opts as { reply_parameters?: { quote?: unknown } }).reply_parameters
133
+ return rp != null && typeof rp === 'object' && rp.quote != null
134
+ }
135
+
136
+ /**
137
+ * Same send options with the surgical quote removed — the retry payload after a
138
+ * {@link isQuoteRejectionError}. Everything else (reply target, thread, markup,
139
+ * notification) is preserved, so the answer still lands as a reply to the right
140
+ * message; only the highlight is sacrificed.
141
+ */
142
+ export function dropQuoteFromSendOpts<T extends object>(opts: T): T {
143
+ if (!sendOptsHaveQuote(opts)) return { ...opts }
144
+ const rp = { ...((opts as { reply_parameters: Record<string, unknown> }).reply_parameters) }
145
+ delete rp.quote
146
+ delete rp.quote_parse_mode
147
+ delete rp.quote_entities
148
+ delete rp.quote_position
149
+ return { ...opts, reply_parameters: rp }
150
+ }