switchroom 0.19.39 → 0.19.41

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 (37) hide show
  1. package/dist/agent-scheduler/index.js +10 -1
  2. package/dist/auth-broker/index.js +12 -3
  3. package/dist/cli/notion-write-pretool.mjs +10 -1
  4. package/dist/cli/switchroom.js +562 -196
  5. package/dist/host-control/main.js +287 -20
  6. package/dist/vault/approvals/kernel-server.js +12 -3
  7. package/dist/vault/broker/server.js +12 -3
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +10 -0
  10. package/telegram-plugin/bridge/bridge.ts +2 -2
  11. package/telegram-plugin/dist/bridge/bridge.js +10 -2
  12. package/telegram-plugin/dist/gateway/gateway.js +549 -232
  13. package/telegram-plugin/dist/server.js +10 -2
  14. package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
  15. package/telegram-plugin/gateway/checklist-fallback.ts +370 -0
  16. package/telegram-plugin/gateway/compaction-marker.ts +84 -0
  17. package/telegram-plugin/gateway/gateway.ts +72 -72
  18. package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
  19. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  20. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  21. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  22. package/telegram-plugin/gateway/stream-render.ts +39 -1
  23. package/telegram-plugin/gateway/turn-record-status.ts +80 -0
  24. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  25. package/telegram-plugin/hooks/hooks.json +11 -0
  26. package/telegram-plugin/session-tail.ts +20 -0
  27. package/telegram-plugin/silence-poke.ts +28 -0
  28. package/telegram-plugin/tests/checklist-fallback.test.ts +317 -0
  29. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +10 -6
  30. package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
  31. package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
  32. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
  33. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
  34. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
  35. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  36. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
  37. package/telegram-plugin/tests/turn-record-status.test.ts +62 -0
@@ -35,6 +35,7 @@ import {
35
35
  type AskUserOutcome,
36
36
  } from '../ask-user.js'
37
37
  import { redactAskUserFields, redactChecklistFields } from '../outbound-field-redact.js'
38
+ import { createChecklistStore, checklistStoreKey, performSendChecklist, performUpdateChecklist, sendChecklistToolText, updateChecklistToolText } from './checklist-fallback.js'
38
39
  import { parseInterruptMarker } from '../interrupt-marker.js'
39
40
  import {
40
41
  ToolFlightTracker,
@@ -1383,65 +1384,29 @@ let _rawEditMessageChecklist: unknown
1383
1384
  /** True when the connected Telegram Bot API supports native checklists. Set in initGatewayBot() (#2996 P0b). */
1384
1385
  let CHECKLIST_API_AVAILABLE = false
1385
1386
 
1386
- /**
1387
- * Send a native Telegram checklist message.
1388
- * Wraps bot.api.raw.sendChecklist with string→number coercion (chat_id) and
1389
- * a 30-task cap enforced before the API call.
1390
- */
1391
- async function rawSendChecklist(args: {
1392
- chat_id: string
1393
- title: string
1394
- tasks: Array<{ text: string; done?: boolean }>
1395
- message_thread_id?: number
1396
- reply_to_message_id?: number
1397
- protect_content?: boolean
1398
- }): Promise<{ message_id: number }> {
1399
- if (!CHECKLIST_API_AVAILABLE) {
1400
- throw new Error('sendChecklist is not available in this grammY/Telegram Bot API version')
1401
- }
1402
- const MAX_TASKS = 30
1403
- if (args.tasks.length > MAX_TASKS) {
1404
- throw new Error(`checklist exceeds ${MAX_TASKS}-task limit (got ${args.tasks.length})`)
1405
- }
1406
- const result = await (_rawSendChecklist as (p: Record<string, unknown>) => Promise<{ message_id: number }>)({
1407
- chat_id: Number(args.chat_id),
1408
- title: args.title,
1409
- tasks: args.tasks.map(t => ({ text: t.text, ...(t.done != null ? { is_completed: t.done } : {}) })),
1410
- ...(args.message_thread_id != null ? { message_thread_id: args.message_thread_id } : {}),
1411
- ...(args.reply_to_message_id != null ? { reply_to_message_id: args.reply_to_message_id } : {}),
1412
- ...(args.protect_content === true ? { protect_content: true } : {}),
1413
- })
1387
+ /** bot.api.raw.sendChecklist with a payload pre-built by checklist-fallback.ts
1388
+ * (nested `checklist` object, per-task integer ids, business_connection_id —
1389
+ * the old flat shape got `400: parameter "checklist" is required`). */
1390
+ async function rawSendChecklist(payload: Record<string, unknown>): Promise<{ message_id: number }> {
1391
+ if (!CHECKLIST_API_AVAILABLE) throw new Error('sendChecklist is not available in this grammY/Telegram Bot API version')
1392
+ const result = await (_rawSendChecklist as (p: Record<string, unknown>) => Promise<{ message_id: number }>)(payload)
1414
1393
  return { message_id: result.message_id }
1415
1394
  }
1416
1395
 
1417
- /**
1418
- * Edit (patch) an existing Telegram checklist message.
1419
- * Supports updating title, adding/removing tasks, and marking tasks done/undone.
1420
- * Task objects with an `id` field target existing tasks; those without are added.
1421
- */
1422
- async function rawEditMessageChecklist(args: {
1423
- chat_id: string
1424
- message_id: string
1425
- title?: string
1426
- tasks?: Array<{ id?: string; text?: string; done?: boolean }>
1427
- }): Promise<void> {
1428
- if (!CHECKLIST_API_AVAILABLE) {
1429
- throw new Error('editMessageChecklist is not available in this grammY/Telegram Bot API version')
1430
- }
1431
- await (_rawEditMessageChecklist as (p: Record<string, unknown>) => Promise<unknown>)({
1432
- chat_id: Number(args.chat_id),
1433
- message_id: Number(args.message_id),
1434
- ...(args.title != null ? { title: args.title } : {}),
1435
- ...(args.tasks != null
1436
- ? {
1437
- tasks: args.tasks.map(t => ({
1438
- ...(t.id != null ? { id: Number(t.id) } : {}),
1439
- ...(t.text != null ? { text: t.text } : {}),
1440
- ...(t.done != null ? { is_completed: t.done } : {}),
1441
- })),
1442
- }
1443
- : {}),
1444
- })
1396
+ /** bot.api.raw.editMessageChecklist with a pre-built payload (the native edit REPLACES the whole checklist, so checklist-fallback.ts sends full state). */
1397
+ async function rawEditMessageChecklist(payload: Record<string, unknown>): Promise<void> {
1398
+ if (!CHECKLIST_API_AVAILABLE) throw new Error('editMessageChecklist is not available in this grammY/Telegram Bot API version')
1399
+ await (_rawEditMessageChecklist as (p: Record<string, unknown>) => Promise<unknown>)(payload)
1400
+ }
1401
+
1402
+ /** Per-message checklist state — update_checklist re-renders from it (process-local; post-restart updates degrade gracefully). */
1403
+ const checklistStore = createChecklistStore()
1404
+
1405
+ /** Native checklists are Telegram-Business-only (sendChecklist REQUIRES a business_connection_id).
1406
+ * No fleet config plumbing exists yet; this env var is the opt-in — unset (the normal case) means text fallback. */
1407
+ function resolveBusinessConnectionId(): string | undefined {
1408
+ const v = process.env.SWITCHROOM_TELEGRAM_BUSINESS_CONNECTION_ID?.trim()
1409
+ return v ? v : undefined
1445
1410
  }
1446
1411
 
1447
1412
  const chatLock = createChatLock()
@@ -2463,7 +2428,7 @@ async function deliverAnswer(args: {
2463
2428
  replyToMessageId: number | null
2464
2429
  /** #3282 captured-answer RESUME (see captured-answer-resume.ts): re-deliver the SAME byte-identical chunks + pre-hydrate the ledger (unsent tail only; a landed chunk is re-probed, never re-sent). */
2465
2430
  resume?: { snapshot: CapturedDeliverySnapshot; hydrate: (ledger: BackstopDeliveryLedger, turnId: string) => void }
2466
- }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
2431
+ /** Duplicate-message race fix — forwarded to `runBackstopDelivery` as `onAckClaim` (claims the nonce at send-ack, before the read-back probe; see backstop-delivery.ts). */ onAckClaim?: (sentIds: number[]) => void }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
2467
2432
  const { chatId, turnId } = args
2468
2433
  // Spacers into `\n\n` gaps then split (as executeReply); a resume re-delivers the EXACT captured chunks (byte-stable, no re-split).
2469
2434
  const chunks = args.resume
@@ -2574,7 +2539,7 @@ async function deliverAnswer(args: {
2574
2539
  args.resume ? null : args.cardMessageId,
2575
2540
  {
2576
2541
  sendChunk,
2577
- readBack,
2542
+ readBack, onAckClaim: args.onAckClaim,
2578
2543
  recordOutbound: HISTORY_ENABLED
2579
2544
  ? (messageIds, texts) => {
2580
2545
  try {
@@ -5038,7 +5003,7 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
5038
5003
  startedAt: turn.startedAt,
5039
5004
  toolCallCount: turn.toolCallCount ?? 0,
5040
5005
  turnId: turn.turnId,
5041
- finalAnswerDelivered: turn.finalAnswerDelivered,
5006
+ finalAnswerDelivered: turn.finalAnswerDelivered, replyCalled: turn.replyCalled, // replyCalled: honest delivery-route signal (turn-record-status.ts computeTurnRoute)
5042
5007
  deliveryOutcome: turn.deliveryOutcome, landedUnconfirmed: turn.landedUnconfirmed,
5043
5008
  },
5044
5009
  endedAt,
@@ -9838,7 +9803,7 @@ function runDeliveryConfirmSweep(): void {
9838
9803
  const _deliveryConfirmSweep = isGatewayMain ? setInterval(runDeliveryConfirmSweep, DELIVERY_CONFIRM_SWEEP_MS) : undefined
9839
9804
  _deliveryConfirmSweep?.unref?.()
9840
9805
 
9841
- startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, resolveReplyMarkup: makeOutboxListenMarkupResolver({ resolveVoiceOutPlan: (t) => resolveVoiceOutPlan(loadAccess().voice_out, t), cachePut: (token, payload) => voiceOnDemandCache.put(token, payload), eagerVoiceEnabled, enqueuePreSynth: (j) => voicePreSynthQueue.enqueue(j) }), log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook prose; resolveReplyMarkup keeps the #3502 Listen button on net-delivered answers (../outbox.ts)
9806
+ startOutboxSweep({ isGatewayMain, stateDir: STATE_DIR, getBot: () => bot, getTurnsDb: () => turnsDb, dedupCheck: (c, t, x) => outboundDedup.check(c, t, x, Date.now()) != null, recordOutbound: HISTORY_ENABLED ? (chatId, threadId, message_ids, texts) => { try { recordOutbound({ chat_id: chatId, thread_id: threadId, message_ids, texts }) } catch { /* best-effort */ } } : undefined, resolveReplyMarkup: makeOutboxListenMarkupResolver({ resolveVoiceOutPlan: (t) => resolveVoiceOutPlan(loadAccess().voice_out, t), cachePut: (token, payload) => voiceOnDemandCache.put(token, payload), eagerVoiceEnabled, enqueuePreSynth: (j) => voicePreSynthQueue.enqueue(j) }), log: (l) => process.stderr.write(l) }) // outbox: single deliverer for Stop-hook prose; resolveReplyMarkup keeps the #3502 Listen button on net-delivered answers; recordOutbound persists the net-delivered answer to history.db (../outbox.ts)
9842
9807
 
9843
9808
  // #1445 cross-turn pending-async ambient. When a turn ends after the
9844
9809
  // model dispatched background async work (Agent / Task / Bash run-in-
@@ -12083,17 +12048,35 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
12083
12048
  (t) => redactOutboundText(t, 'send_checklist'),
12084
12049
  )
12085
12050
 
12086
- const sent = await rawSendChecklist({
12087
- chat_id,
12088
- title: redactedTitle!,
12089
- tasks: redactedTasks!,
12051
+ // Graceful degradation: native sendChecklist is Business-account-only, so
12052
+ // ordinary chats (the fleet norm) get a formatted text render instead of a
12053
+ // raw Telegram 400 (rationale + orchestration in checklist-fallback.ts).
12054
+ const literal = (loadAccess().parseMode ?? 'html') === 'text'
12055
+ const sendOpts = {
12090
12056
  ...(threadId != null ? { message_thread_id: threadId } : {}),
12091
- ...(replyTo != null ? { reply_to_message_id: replyTo } : {}),
12057
+ ...(replyTo != null ? { reply_parameters: { message_id: replyTo } } : {}),
12092
12058
  ...(protectContent ? { protect_content: true } : {}),
12093
- })
12059
+ }
12060
+ const result = await performSendChecklist({
12061
+ businessConnectionId: resolveBusinessConnectionId(),
12062
+ nativeAvailable: CHECKLIST_API_AVAILABLE,
12063
+ sendNative: rawSendChecklist,
12064
+ sendText: (text) => robustApiCall(
12065
+ (): Promise<{ message_id: number }> =>
12066
+ literal
12067
+ // allow-raw-bot-api: literal checklist text-fallback send routed through robustApiCall
12068
+ ? lockedBot.api.sendMessage(chat_id, text, sendOpts as never)
12069
+ // allow-raw-bot-api: checklist text-fallback send routed through robustApiCall
12070
+ : lockedBot.api.sendRichMessage(chat_id, richMessage(text), sendOpts as never),
12071
+ { verb: 'sendMessage', chat_id, threadId },
12072
+ ),
12073
+ literalText: literal,
12074
+ log: (l) => process.stderr.write(`telegram gateway: ${l}`),
12075
+ }, { title: redactedTitle!, tasks: redactedTasks!, chatId: Number(chat_id), replyToMessageId: replyTo, protectContent })
12094
12076
 
12095
- process.stderr.write(`telegram gateway: send_checklist: sent chatId=${chat_id} messageId=${sent.message_id} tasks=${tasks.length}\n`)
12096
- return { content: [{ type: 'text', text: `checklist sent (id: ${sent.message_id})` }] }
12077
+ checklistStore.set(checklistStoreKey(chat_id, result.message_id), result.state)
12078
+ process.stderr.write(`telegram gateway: send_checklist: sent chatId=${chat_id} messageId=${result.message_id} tasks=${tasks.length} mode=${result.mode}\n`)
12079
+ return { content: [{ type: 'text', text: sendChecklistToolText(result) }] }
12097
12080
  }
12098
12081
 
12099
12082
  /**
@@ -12180,10 +12163,27 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
12180
12163
  (t) => redactOutboundText(t, 'update_checklist'),
12181
12164
  )
12182
12165
 
12183
- await rawEditMessageChecklist({ chat_id, message_id, title: redactedTitle, tasks: redactedTasks })
12184
-
12185
- process.stderr.write(`telegram gateway: update_checklist: updated chatId=${chat_id} messageId=${message_id}\n`)
12186
- return { content: [{ type: 'text', text: `checklist updated (id: ${message_id})` }] }
12166
+ // Graceful degradation (checklist-fallback.ts): the patch is applied to the
12167
+ // stored per-message state and the message re-rendered in the mode it was
12168
+ // sent in. Failures come back structured, never as a raw Telegram 400.
12169
+ const literal = (loadAccess().parseMode ?? 'html') === 'text'
12170
+ const key = checklistStoreKey(chat_id, message_id)
12171
+ const result = await performUpdateChecklist({
12172
+ state: checklistStore.get(key),
12173
+ businessConnectionId: resolveBusinessConnectionId(),
12174
+ nativeAvailable: CHECKLIST_API_AVAILABLE,
12175
+ editNative: rawEditMessageChecklist,
12176
+ // allow-raw-bot-api: checklist text-fallback edit routed through robustApiCall
12177
+ editText: async (text) => { await robustApiCall(() => lockedBot.api.editMessageText(chat_id, Number(message_id), literal ? text : richMessage(text))) },
12178
+ literalText: literal,
12179
+ log: (l) => process.stderr.write(`telegram gateway: ${l}`),
12180
+ chatId: Number(chat_id),
12181
+ messageId: Number(message_id),
12182
+ }, { title: redactedTitle, tasks: redactedTasks })
12183
+
12184
+ if (result.ok) checklistStore.set(key, result.state)
12185
+ process.stderr.write(`telegram gateway: update_checklist: ${result.ok ? `updated mode=${result.mode}` : `failed reason=${result.reason}`} chatId=${chat_id} messageId=${message_id}\n`)
12186
+ return { content: [{ type: 'text', text: updateChecklistToolText(result, Number(message_id)) }] }
12187
12187
  }
12188
12188
 
12189
12189
  /**
@@ -25,6 +25,7 @@ import { logStreamingEvent } from '../streaming-metrics.js'
25
25
  import { clearSilentEndState } from '../silent-end.js'
26
26
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
27
27
  import { removeTurnActiveMarker, readTurnActiveMarkerAgeMs } from './turn-active-marker.js'
28
+ import { readCompactionMarkerAgeMs } from './compaction-marker.js'
28
29
  import { decideHangRestart } from './hang-restart-decision.js'
29
30
  import { recordTurnEnd } from '../registry/turns-schema.js'
30
31
  import { hostdGetStatusOnce } from './hostd-dispatch.js'
@@ -70,6 +71,20 @@ export function buildSilencePokeOptions(deps: LivenessWiringDeps): Parameters<ty
70
71
  thresholdsMs: { fallback: SILENCE_FALLBACK_MS, fallbackHardCeiling: SILENCE_FALLBACK_HARD_MS, floor: SILENCE_FLOOR_MS },
71
72
  deferFallbackWhileToolInFlight: SILENCE_DEFER_INFLIGHT_TOOLS,
72
73
  isLegitimatelyWorking: (key) => isLegitimatelyWorking(key),
74
+ // #4058 — mid-turn auto-compaction defer. The PreCompact hook writes the
75
+ // compaction marker at compaction START (the transcript only records the
76
+ // END, via `compact_boundary`); while the marker is present AND younger
77
+ // than the fallback hard ceiling, silence-poke defers the 300s fallback the
78
+ // same way it does for an in-flight tool. The age bound makes a marker
79
+ // leaked by a crash (boundary never observed) self-heal: it can never hold
80
+ // a future genuine wedge past one ceiling window. The marker is process-
81
+ // wide, not keyed — the gateway runs ONE Claude session, so a compaction
82
+ // belongs to whichever turn is live (same accepted trade-off as
83
+ // `toolFlightTracker` in gateway.ts `isLegitimatelyWorking`).
84
+ isCompactionInFlight: () => {
85
+ const ageMs = readCompactionMarkerAgeMs(STATE_DIR)
86
+ return ageMs != null && ageMs >= 0 && ageMs < SILENCE_FALLBACK_HARD_MS
87
+ },
73
88
  // #3552 — orphan-state reaper predicate. Deliberately the SAME condition the
74
89
  // `onFrameworkFallback` late-fire guard below uses: no `activeTurnStartedAt`
75
90
  // entry AND no current turn ⇒ the turn this state belongs to is over. Note
@@ -2663,6 +2663,8 @@ export async function deliverCapturedProse(
2663
2663
  const plainChunks = splitMarkdownChunks(plain, RICH_MESSAGE_MAX_CHARS)
2664
2664
  try {
2665
2665
  let liveThreadId: number | undefined = threadId
2666
+ const plainSentIds: number[] = []
2667
+ const plainSentTexts: string[] = []
2666
2668
  for (const c of plainChunks) {
2667
2669
  // Plain sendMessage — NO parse_mode / rich rendering — so a markdown
2668
2670
  // construct that made sendRichMessage 400 is sent verbatim instead.
@@ -2676,6 +2678,8 @@ export async function deliverCapturedProse(
2676
2678
  ),
2677
2679
  { threadId: liveThreadId, chat_id: chatId, verb: 'captured-prose-plain-fallback.sendMessage' },
2678
2680
  )
2681
+ const sentId = (sent as { message_id?: number }).message_id
2682
+ if (sentId != null) { plainSentIds.push(sentId); plainSentTexts.push(c) }
2679
2683
  if (liveThreadId != null && (sent as { message_thread_id?: number }).message_thread_id == null) {
2680
2684
  liveThreadId = undefined
2681
2685
  }
@@ -2683,6 +2687,22 @@ export async function deliverCapturedProse(
2683
2687
  // The real answer reached the user via plain text — record it so a late
2684
2688
  // reply-tool retry with the same content is deduped at its send site.
2685
2689
  outboundDedup.record(chatId, threadId, text, Date.now(), registryKey)
2690
+ // Persist parity: the recovered answer reached the user, so it must land in
2691
+ // history.db too (mirrors the canonical persist above at the recordOutbound
2692
+ // reply site). Without this the plain-text-recovered answer is silently
2693
+ // absent from get_recent_messages / handoff briefings / the represent
2694
+ // guard's outbound counting. Best-effort — the delivery already succeeded.
2695
+ if (HISTORY_ENABLED && plainSentIds.length > 0) {
2696
+ try {
2697
+ recordOutbound({ chat_id: chatId, thread_id: threadId ?? null, message_ids: plainSentIds, texts: plainSentTexts })
2698
+ } catch (histErr) {
2699
+ process.stderr.write(
2700
+ `telegram gateway: history recordOutbound (captured-prose plain fallback) failed: ${
2701
+ histErr instanceof Error ? histErr.message : String(histErr)
2702
+ }\n`,
2703
+ )
2704
+ }
2705
+ }
2686
2706
  process.stderr.write(
2687
2707
  `telegram gateway: captured-prose recovered via plain-text fallback ` +
2688
2708
  `(chat=${chatId} origin=${originTurnId})\n`,
@@ -43,6 +43,8 @@ import {
43
43
  type OutboxRecord,
44
44
  } from '../outbox.js'
45
45
  import { isShownBlock } from '../shown-ledger.js'
46
+ import { richMessage, isParseEntitiesError } from '../rich-send.js'
47
+ import { splitMarkdownChunks } from '../format.js'
46
48
  import { resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
47
49
  import { createRetryApiCall, retryWithThreadFallback } from '../retry-api-call.js'
48
50
  import {
@@ -51,13 +53,46 @@ import {
51
53
  makeFloodWaitRecorder,
52
54
  } from '../flood-circuit-breaker.js'
53
55
 
56
+ /**
57
+ * What a sweep `send` reports back. The primary (last) landed message id is the
58
+ * journal's `tgMessageId`; the per-chunk `chunks` are what {@link sweepOutbox}
59
+ * hands to `recordOutbound` so the history row is aligned to the ACTUAL sends
60
+ * (a markdown-aware split can land >1 chunk), exactly like the backstop uses
61
+ * `ledger.entries` (`backstop-delivery.ts`).
62
+ */
63
+ export interface OutboxSendResult {
64
+ /** Primary (last) landed message id, or `undefined` when nothing was sent. */
65
+ messageId: number | undefined
66
+ /** Every landed chunk in delivery order: its message id + the delivered text. */
67
+ chunks: Array<{ messageId: number; text: string }>
68
+ }
69
+
54
70
  export interface OutboxSweepDeps {
55
- /** Deliver `text` to the chat. Resolves to the primary message id (best-effort). */
71
+ /**
72
+ * Deliver `text` to the chat. Resolves to the landed message id(s) + their
73
+ * texts (best-effort) — see {@link OutboxSendResult}. A long body is chunked
74
+ * inside the sender, so more than one message may land.
75
+ */
56
76
  send: (
57
77
  chatId: string,
58
78
  threadId: number | null,
59
79
  text: string,
60
- ) => Promise<number | undefined>
80
+ ) => Promise<OutboxSendResult>
81
+ /**
82
+ * Persist the sweep-delivered final answer to history (role='assistant'),
83
+ * the SAME `recordOutbound` the reply path (`outbound-send-path.ts`) and the
84
+ * turn-flush backstop (`backstop-delivery.ts`) call. Without it every
85
+ * net-delivered handback / task-notification answer is silently absent from
86
+ * `history.db`, degrading `get_recent_messages`, the handoff briefing, and the
87
+ * represent-guard's outbound counting. OPTIONAL and called DEFENSIVELY (a
88
+ * missing recorder or a throw never breaks the safety-net delivery).
89
+ */
90
+ recordOutbound?: (
91
+ chatId: string,
92
+ threadId: number | null,
93
+ messageIds: number[],
94
+ texts: string[],
95
+ ) => void
61
96
  /** Has this exact text already been delivered to this chat/thread recently? */
62
97
  textAlreadyDelivered: (chatId: string, threadId: number | null, text: string) => boolean
63
98
  /**
@@ -241,7 +276,7 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
241
276
  const resolvedChat = resolved! // routable implies non-null
242
277
 
243
278
  try {
244
- const messageId = await deps.send(
279
+ const sendResult = await deps.send(
245
280
  resolvedChat.chatId,
246
281
  resolvedChat.threadId,
247
282
  decision.text ?? record.text,
@@ -250,7 +285,7 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
250
285
  {
251
286
  turnNonce: record.turnNonce,
252
287
  textSha256: record.textSha256,
253
- tgMessageId: messageId,
288
+ tgMessageId: sendResult.messageId,
254
289
  ts: now,
255
290
  // #3510 instrumentation: a sweep delivery journals its machine and the
256
291
  // record's capture-time reply-already-delivered flag, so a duplicate
@@ -261,6 +296,22 @@ export async function sweepOutbox(deps: OutboxSweepDeps): Promise<OutboxSweepSum
261
296
  },
262
297
  deps.stateDir,
263
298
  )
299
+ // Persist parity (mirrors outbound-send-path.ts + backstop-delivery.ts):
300
+ // record the delivered chunk ids + texts to history so a net-delivered
301
+ // answer is not silently absent from history.db. Best-effort — a missing
302
+ // recorder or a throw must never break this safety-net delivery.
303
+ if (deps.recordOutbound != null && sendResult.chunks.length > 0) {
304
+ try {
305
+ deps.recordOutbound(
306
+ resolvedChat.chatId,
307
+ resolvedChat.threadId,
308
+ sendResult.chunks.map((c) => c.messageId),
309
+ sendResult.chunks.map((c) => c.text),
310
+ )
311
+ } catch {
312
+ /* best-effort — the delivery already landed; never throw here */
313
+ }
314
+ }
264
315
  removeClaimed(record.turnNonce, deps.stateDir)
265
316
  summary.delivered++
266
317
  log(
@@ -380,7 +431,9 @@ export type OutboxDeliveryMarkup = {
380
431
  inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
381
432
  }
382
433
 
383
- /** Minimal bot-api surface the sweep needs to deliver text. */
434
+ /** Minimal bot-api surface the sweep needs to deliver text. `sendRichMessage`
435
+ * is the canonical rendered path (raw GFM markdown → entities); `sendMessage`
436
+ * is the plain parse-reject fallback. */
384
437
  type OutboxSendBot = {
385
438
  api: {
386
439
  sendMessage: (
@@ -388,6 +441,40 @@ type OutboxSendBot = {
388
441
  text: string,
389
442
  opts: object,
390
443
  ) => Promise<{ message_id?: number }>
444
+ sendRichMessage: (
445
+ chatId: string,
446
+ body: { markdown: string },
447
+ opts: object,
448
+ ) => Promise<{ message_id?: number }>
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Send ONE chunk with RENDER PARITY: ship raw GFM markdown through the shared
454
+ * `richMessage` → `sendRichMessage` path — the SAME renderer the reply path
455
+ * uses (`outbound-send-path.ts`) — so a net-delivered answer is formatted
456
+ * identically to a normally-delivered one (no more literal `**bold**`). On a
457
+ * markdown PARSE-REJECT, resend the same chunk as PLAIN text (the raw markdown
458
+ * source is itself readable prose), mirroring the reply path's plaintext
459
+ * fallback (`outbound-send-path.ts`). A length / thread / flood error is NOT
460
+ * caught here — it propagates so `retryWithThreadFallback` and the sweep's
461
+ * claim-release retry handle it, exactly as before.
462
+ */
463
+ async function sendChunkRich(
464
+ bot: OutboxSendBot,
465
+ chatId: string,
466
+ chunk: string,
467
+ opts: object,
468
+ ): Promise<{ message_id?: number }> {
469
+ try {
470
+ // allow-raw-bot-api: caller (createOutboxSend) invokes this inside retryWithThreadFallback + createRetryApiCall
471
+ return await bot.api.sendRichMessage(chatId, richMessage(chunk), opts)
472
+ } catch (err) {
473
+ if (isParseEntitiesError(err)) {
474
+ // allow-raw-bot-api: parse-reject plain fallback, still inside the sweep's retryWithThreadFallback wrapper
475
+ return await bot.api.sendMessage(chatId, chunk, opts)
476
+ }
477
+ throw err
391
478
  }
392
479
  }
393
480
 
@@ -419,19 +506,21 @@ export function createOutboxSend(deps: {
419
506
  // so sending one chunk of '' would throw every tick and wedge the sweep in
420
507
  // a permanent retry (the record never journals → never clears). Return
421
508
  // early, matching the pre-refactor loop's zero-chunk behaviour.
422
- if (text.length === 0) return undefined
509
+ if (text.length === 0) return { messageId: undefined, chunks: [] }
423
510
  // Resolve the Listen button / keyboard ONCE from the full answer text; it
424
511
  // rides only on the final chunk below.
425
512
  const replyMarkup = deps.resolveReplyMarkup?.(chatId, threadId, text)
426
- // Chunk to Telegram's 4096-char ceiling; each chunk goes through the
427
- // standard retry / flood-wait / thread-fallback wrapper. A thrown send
428
- // propagates so the sweep releases the claim and retries next tick (the
429
- // record is never journaled never lost).
430
- let lastId: number | undefined
431
- const chunkCount = Math.ceil(text.length / 4000)
432
- for (let i = 0, idx = 0; i < text.length; i += 4000, idx++) {
433
- const chunk = text.slice(i, i + 4000)
434
- const isLast = idx === chunkCount - 1
513
+ // Chunk with the markdown-boundary-aware splitter the reply path uses
514
+ // (`splitMarkdownChunks`), NOT a blind fixed-char slice a raw slice can
515
+ // bisect an inline entity (`**bold**`) and force a parse-reject. Each chunk
516
+ // goes through the standard retry / flood-wait / thread-fallback wrapper. A
517
+ // thrown send propagates so the sweep releases the claim and retries next
518
+ // tick (the record is never journaled → never lost).
519
+ const landed: Array<{ messageId: number; text: string }> = []
520
+ const chunks = splitMarkdownChunks(text)
521
+ for (let idx = 0; idx < chunks.length; idx++) {
522
+ const chunk = chunks[idx]
523
+ const isLast = idx === chunks.length - 1
435
524
  const res = await retryWithThreadFallback(
436
525
  deps.retry,
437
526
  (tid) => {
@@ -439,13 +528,17 @@ export function createOutboxSend(deps: {
439
528
  // Button on the LAST chunk only (final visible message).
440
529
  const opts =
441
530
  isLast && replyMarkup != null ? { ...base, reply_markup: replyMarkup } : base
442
- return bot.api.sendMessage(chatId, chunk, opts)
531
+ return sendChunkRich(bot, chatId, chunk, opts)
443
532
  },
444
533
  { threadId: threadId ?? undefined, chat_id: chatId, verb: 'outbox-sweep.sendMessage' },
445
534
  )
446
- lastId = res?.message_id
535
+ const id = res?.message_id
536
+ if (id != null) landed.push({ messageId: id, text: chunk })
537
+ }
538
+ return {
539
+ messageId: landed.length > 0 ? landed[landed.length - 1]!.messageId : undefined,
540
+ chunks: landed,
447
541
  }
448
- return lastId
449
542
  }
450
543
  }
451
544
 
@@ -463,6 +556,10 @@ export function startOutboxSweep(deps: {
463
556
  threadId: number | null,
464
557
  text: string,
465
558
  ) => OutboxDeliveryMarkup | undefined
559
+ /** Persist a sweep-delivered answer to history — forwarded to {@link sweepOutbox}
560
+ * as {@link OutboxSweepDeps.recordOutbound}. Wired by the gateway to the shared
561
+ * `recordOutbound` (history.ts). Absent → no history row (legacy behaviour). */
562
+ recordOutbound?: OutboxSweepDeps['recordOutbound']
466
563
  log?: (line: string) => void
467
564
  /** Test seam — override the flood probe (default: the persisted breaker). */
468
565
  floodWaitRemainingMs?: () => number
@@ -500,6 +597,7 @@ export function startOutboxSweep(deps: {
500
597
  stateDir: deps.stateDir,
501
598
  log: deps.log,
502
599
  send,
600
+ ...(deps.recordOutbound != null ? { recordOutbound: deps.recordOutbound } : {}),
503
601
  floodWaitRemainingMs: floodProbe,
504
602
  textAlreadyDelivered: (chatId, threadId, text) => deps.dedupCheck(chatId, threadId ?? undefined, text),
505
603
  registryChainLookup: (taskId) => {
@@ -12,6 +12,7 @@ import type { SessionEvent } from '../session-tail.js'
12
12
  import { isTelegramSurfaceTool } from '../tool-names.js'
13
13
  import { toolLabel } from '../tool-labels.js'
14
14
  import { applyBackgroundShellLiveness } from './background-shell-liveness.js'
15
+ import { removeCompactionMarker, resolveTelegramStateDir } from './compaction-marker.js'
15
16
 
16
17
  /** The silence-poke module namespace (kept as `typeof` so no surface drift). */
17
18
  type SilencePoke = typeof import('../silence-poke.js')
@@ -83,6 +84,18 @@ export function applySilencePokeSessionEvent(
83
84
  if (ev.toolUseId != null && ev.toolUseId.length > 0) {
84
85
  silencePoke.noteToolEnd(key, ev.toolUseId, Date.now())
85
86
  }
87
+ } else if (ev.kind === 'compact_boundary') {
88
+ // #4058 — mid-turn compaction ENDED (the transcript's compact_boundary
89
+ // record only exists once compaction is done). Two effects:
90
+ // 1. Clear the PreCompact marker so the compaction defer stops holding
91
+ // the 300s fallback (a post-compaction wedge fires on schedule).
92
+ // 2. Count the boundary as PRODUCTION: the silence clock ran the whole
93
+ // compaction, so without a reset the fallback would fire on the very
94
+ // next tick — before the resumed model's first output can land. The
95
+ // transcript resuming IS observable progress; a turn that stays
96
+ // silent AFTER compaction still fires one full window later.
97
+ removeCompactionMarker(resolveTelegramStateDir())
98
+ silencePoke.noteProduction(key, Date.now())
86
99
  }
87
100
  // #3519 sharpen: feed background-shell liveness (ALIVE/DEAD) to silence-poke — see background-shell-liveness.ts.
88
101
  applyBackgroundShellLiveness(silencePoke, key, ev)
@@ -2312,6 +2312,10 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2312
2312
  let sentIds: number[] = []
2313
2313
  let chunkCount = 0
2314
2314
  let delivered = false
2315
+ // Set by the send-ack claim callback below once the durable
2316
+ // delivered-keys journal has been written at ACK time — so the finally
2317
+ // block does not journal the same nonce a second time.
2318
+ let earlyJournaled = false
2315
2319
  try {
2316
2320
  // #3276 — the ONE delivery primitive. deliverAnswer routes through
2317
2321
  // `sendReplyChunks` (the same send core executeReply uses) and posts
@@ -2330,6 +2334,33 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2330
2334
  // S4 — anchor the flushed answer to the inbound it answers
2331
2335
  // (null for synthesized turns, which send bare as before).
2332
2336
  replyToMessageId: turn.sourceMessageId,
2337
+ // Duplicate-message race fix — write the DURABLE delivered-keys
2338
+ // journal at SEND-ACK, BEFORE deliverAnswer's read-back probe (which
2339
+ // the cosmetic-edit flood-fuse can defer ~30s). Without this the
2340
+ // journal write happened only in the finally below, AFTER `await
2341
+ // deliverAnswer` returned, so the outbox sweep — which waits just
2342
+ // OUTBOX_QUIET_MS (5s) before checking `deliveredNonces` — saw no
2343
+ // entry and sent a SECOND copy of this answer. Journaling under the
2344
+ // SAME nonce (turn.turnId == the Stop-hook record's turnNonce) makes
2345
+ // the sweep skip-journaled and clears any captured record.
2346
+ onAckClaim: (ackIds) => {
2347
+ try {
2348
+ journalExternalDelivery(
2349
+ {
2350
+ turnNonce: turn.turnId,
2351
+ text: capturedText,
2352
+ tgMessageId: ackIds.length > 0 ? ackIds[0] : undefined,
2353
+ deliverySource: 'flush',
2354
+ },
2355
+ STATE_DIR,
2356
+ )
2357
+ earlyJournaled = true
2358
+ } catch (err) {
2359
+ process.stderr.write(
2360
+ `telegram gateway: turn-flush send-ack journal write failed (non-fatal): ${(err as Error).message}\n`,
2361
+ )
2362
+ }
2363
+ },
2333
2364
  })
2334
2365
  sentIds = delivery.sentIds
2335
2366
  chunkCount = delivery.chunkCount
@@ -2433,7 +2464,14 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
2433
2464
  // between this send and the next process's backstop. Journal ONLY on
2434
2465
  // a receipt-gated `delivered` success. Best-effort: a journal-write
2435
2466
  // failure must never demote the successful delivery.
2436
- if (delivered) {
2467
+ //
2468
+ // Duplicate-message race fix: skip when the send-ack claim
2469
+ // (`onAckClaim`) already journaled this nonce at ACK time. That
2470
+ // path is the primary writer now (it runs before the read-back
2471
+ // probe); this finally write only covers the case where the claim
2472
+ // never fired — e.g. a card-only delivery where every chunk landed
2473
+ // but no fresh receipt gated the claim, yet `delivered` still held.
2474
+ if (delivered && !earlyJournaled) {
2437
2475
  try {
2438
2476
  journalExternalDelivery(
2439
2477
  {