switchroom 0.19.38 → 0.19.40

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 (45) hide show
  1. package/dist/agent-scheduler/index.js +10 -1
  2. package/dist/auth-broker/index.js +15 -6
  3. package/dist/cli/notion-write-pretool.mjs +10 -1
  4. package/dist/cli/switchroom.js +1141 -566
  5. package/dist/host-control/main.js +220 -8
  6. package/dist/vault/approvals/kernel-server.js +15 -6
  7. package/dist/vault/broker/server.js +15 -6
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +10 -0
  10. package/telegram-plugin/dist/bridge/bridge.js +8 -0
  11. package/telegram-plugin/dist/gateway/gateway.js +450 -206
  12. package/telegram-plugin/dist/server.js +8 -0
  13. package/telegram-plugin/gateway/backstop-delivery.ts +48 -0
  14. package/telegram-plugin/gateway/compaction-marker.ts +84 -0
  15. package/telegram-plugin/gateway/gateway.ts +3 -3
  16. package/telegram-plugin/gateway/handback-preturn-signal.ts +16 -0
  17. package/telegram-plugin/gateway/liveness-wiring.ts +15 -0
  18. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  19. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  20. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  21. package/telegram-plugin/gateway/stream-render.ts +277 -18
  22. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  23. package/telegram-plugin/hooks/hooks.json +11 -0
  24. package/telegram-plugin/hooks/tool-label-pretool.mjs +119 -1
  25. package/telegram-plugin/session-tail.ts +20 -0
  26. package/telegram-plugin/silence-poke.ts +28 -0
  27. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +19 -12
  28. package/telegram-plugin/tests/fixtures/pretool-main-2.1.219.json +15 -0
  29. package/telegram-plugin/tests/fixtures/pretool-subagent-2.1.219.json +16 -0
  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/queued-card-surface.test.ts +262 -0
  36. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  37. package/telegram-plugin/tests/sidechain-label-filter-pretool.test.ts +272 -0
  38. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
  39. package/vendor/hindsight-memory/scripts/lib/client.py +7 -4
  40. package/vendor/hindsight-memory/scripts/lib/config.py +151 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +19 -15
  42. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +14 -1
  43. package/vendor/hindsight-memory/scripts/tests/test_observation_scopes.py +192 -3
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +18 -2
  45. package/vendor/hindsight-memory/scripts/tests/test_subagent_retain.py +57 -4
@@ -17627,6 +17627,14 @@ function projectTranscriptLine(line) {
17627
17627
  { kind: "turn_end", durationMs: obj.durationMs ?? 0 }
17628
17628
  ];
17629
17629
  }
17630
+ if (type === "system" && obj.subtype === "compact_boundary") {
17631
+ const meta = obj.compactMetadata;
17632
+ return [{
17633
+ kind: "compact_boundary",
17634
+ trigger: typeof meta?.trigger === "string" ? meta.trigger : null,
17635
+ compactDurationMs: typeof meta?.durationMs === "number" ? meta.durationMs : null
17636
+ }];
17637
+ }
17630
17638
  return [];
17631
17639
  }
17632
17640
  function projectSubagentLine(line, agentId, state) {
@@ -322,6 +322,28 @@ export interface BackstopDeliveryDeps {
322
322
  messageIds: readonly number[],
323
323
  text: string,
324
324
  ) => Promise<ReadBackResult>
325
+ /**
326
+ * Exactly-once SEND-ACK claim (duplicate-message race fix). Fired EXACTLY ONCE,
327
+ * the first moment every chunk has landed a fresh non-card receipt — i.e. the
328
+ * same condition that makes {@link BackstopDeliveryResult.delivered} true — and
329
+ * crucially BEFORE the read-back probe (step 2 below) is awaited.
330
+ *
331
+ * Why before the probe: the probe is issued at COSMETIC priority and, when the
332
+ * edit-flood-fuse is deferring cosmetic edits, `await deps.readBack` can block
333
+ * ~30s. The caller's durable exactly-once claim (`journalExternalDelivery`)
334
+ * used to run only in its post-`await deliverAnswer` bookkeeping, i.e. AFTER
335
+ * that probe resolved. The out-of-band outbox sweep only waits `OUTBOX_QUIET_MS`
336
+ * (5s) before checking the delivered-keys journal, so during that 5–30s gap it
337
+ * saw no journal entry and sent a SECOND copy of the same answer. Claiming here,
338
+ * at send-ack, closes that window deterministically regardless of probe timing.
339
+ *
340
+ * A later `absent` read-back (positive silent-drop) is recovered by THIS
341
+ * orchestrator's own in-process re-send below — the nonce is NEVER reopened to
342
+ * the sweep, so a possibly-delivered answer is never handed to a second sender.
343
+ * `sentIds` is the landed fresh-receipt set at claim time. Best-effort; a throw
344
+ * is swallowed and never demotes the delivery.
345
+ */
346
+ onAckClaim?: (sentIds: number[]) => void
325
347
  /** Optional stderr sink for progress/resume logging. */
326
348
  stderr?: (s: string) => void
327
349
  }
@@ -406,6 +428,8 @@ export async function runBackstopDelivery(
406
428
  const stderr = deps.stderr ?? (() => {})
407
429
  const chunkCount = chunks.length
408
430
  let attempts = 0
431
+ // Fires the send-ack claim (see BackstopDeliveryDeps.onAckClaim) at most once.
432
+ let ackClaimed = false
409
433
 
410
434
  for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt++) {
411
435
  attempts = attempt
@@ -437,6 +461,30 @@ export async function runBackstopDelivery(
437
461
  }
438
462
  }
439
463
 
464
+ // 1b. EXACTLY-ONCE SEND-ACK CLAIM (duplicate-message race fix). The instant
465
+ // every chunk has landed a fresh non-card receipt, fire the caller's
466
+ // claim — BEFORE the read-back probe below, which can be deferred ~30s
467
+ // by the edit-flood-fuse (cosmetic priority). This is the same predicate
468
+ // as the final `delivered` verdict (all-landed + fresh receipt); the only
469
+ // way `delivered` can subsequently go false is a POSITIVE `absent` probe
470
+ // demoting a chunk, which this orchestrator re-sends in-process (never
471
+ // reopening the nonce to the out-of-band sweep). See onAckClaim.
472
+ if (!ackClaimed && deps.onAckClaim != null) {
473
+ const allLandedNow =
474
+ chunkCount > 0 && ledger.unsentIndices(turnId, chunkCount).length === 0
475
+ if (allLandedNow && backstopReceiptIds(ledger.sentIds(turnId), cardMessageId).length > 0) {
476
+ ackClaimed = true
477
+ try {
478
+ deps.onAckClaim(ledger.sentIds(turnId))
479
+ } catch (err) {
480
+ stderr(
481
+ `telegram gateway: backstop send-ack claim callback threw for turn ${turnId} ` +
482
+ `(non-fatal): ${err instanceof Error ? err.message : String(err)}\n`,
483
+ )
484
+ }
485
+ }
486
+ }
487
+
440
488
  // 2. CONFIRM landed-unconfirmed chunks via read-back (#3278). Without a probe
441
489
  // wired, landing IS confirmation (pre-#3278) — the fresh-non-card receipt
442
490
  // filter below still rejects a card-only "delivery".
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Compaction-in-flight marker (#4058 — spurious 300s silence-fallback fire
3
+ * during mid-turn auto-compaction).
4
+ *
5
+ * The blind spot this closes: when Claude Code hits mid-turn auto-compaction
6
+ * (the session ran out of context and spends minutes summarizing), the model
7
+ * emits ZERO output and runs ZERO tools — the gateway sees pure silence, so
8
+ * the silence-poke 300s framework fallback fired on a perfectly healthy turn:
9
+ * a spurious "⚠️ no output for 5 min — the framework ended that stalled turn"
10
+ * card plus an obligation re-present, right before the turn completed
11
+ * normally. Observed live: ~1m50s of tools then ~3m25s compacting = 304s of
12
+ * "silence" → false fire.
13
+ *
14
+ * The signal problem: the transcript JSONL carries a compaction record ONLY
15
+ * at the END (`{"type":"system","subtype":"compact_boundary",...}` — it
16
+ * embeds the compaction's own durationMs, so it cannot exist before the
17
+ * compaction finishes). Nothing observable lands at the START from the
18
+ * stream. The START therefore comes from Claude Code's PreCompact hook
19
+ * (`hooks/compaction-marker-precompact.mjs`), which writes
20
+ * `<STATE_DIR>/compaction-in-flight.json` the moment compaction begins —
21
+ * the same sidecar-file pattern as `turn-active-marker.ts` and the
22
+ * tool-label sidecar (#783).
23
+ *
24
+ * Consumers:
25
+ * - `liveness-wiring.ts` wires `isCompactionInFlight` into the silence-poke
26
+ * deps: marker present AND younger than the fallback hard ceiling ⇒ the
27
+ * 300s fallback is DEFERRED via the exact same `underCeiling` mechanism
28
+ * as the #1292/#3519 in-flight-tool defers — a genuinely-wedged
29
+ * compaction still unwedges once silence crosses `fallbackHardCeiling`.
30
+ * - `silence-poke-session-event.ts` removes the marker on the
31
+ * `compact_boundary` session event (the deterministic END) and counts
32
+ * the boundary as production, so the resumed turn gets a fresh 300s
33
+ * window instead of firing on the very next tick.
34
+ *
35
+ * Staleness is double-bounded: the reader treats an old marker as absent
36
+ * (age bound supplied by the caller — the hard ceiling), so a marker leaked
37
+ * by a crash between compaction start and boundary can never defer a future
38
+ * genuine wedge for more than one ceiling window.
39
+ *
40
+ * Pure file I/O; clock injectable for tests; never throws.
41
+ */
42
+
43
+ import { statSync, unlinkSync } from 'node:fs'
44
+ import { homedir } from 'node:os'
45
+ import { join } from 'node:path'
46
+
47
+ export const COMPACTION_MARKER_FILE = 'compaction-in-flight.json'
48
+
49
+ /**
50
+ * The gateway's state dir — same resolution as gateway.ts (`STATE_DIR`).
51
+ * Exported so `silence-poke-session-event.ts` (which is handed no stateDir)
52
+ * can clear the marker without widening its call signature.
53
+ */
54
+ export function resolveTelegramStateDir(): string {
55
+ return process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
56
+ }
57
+
58
+ /**
59
+ * Age (ms) of the compaction marker's mtime, or null when absent/unstattable.
60
+ * The PreCompact hook (re)writes the file at compaction start, so a small age
61
+ * means a compaction began recently and may still be running. Never throws.
62
+ */
63
+ export function readCompactionMarkerAgeMs(stateDir: string, now?: number): number | null {
64
+ try {
65
+ const st = statSync(join(stateDir, COMPACTION_MARKER_FILE))
66
+ return (now ?? Date.now()) - st.mtimeMs
67
+ } catch {
68
+ return null // ENOENT / unstattable → no compaction in flight
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Remove the marker — called on the `compact_boundary` session event (the
74
+ * compaction's deterministic END record in the transcript). Idempotent;
75
+ * ENOENT and any other unlink failure are swallowed (the age bound in the
76
+ * reader is the correctness backstop, removal is the fast path).
77
+ */
78
+ export function removeCompactionMarker(stateDir: string): void {
79
+ try {
80
+ unlinkSync(join(stateDir, COMPACTION_MARKER_FILE))
81
+ } catch {
82
+ // best-effort — staleness bound in readCompactionMarkerAgeMs self-heals
83
+ }
84
+ }
@@ -2463,7 +2463,7 @@ async function deliverAnswer(args: {
2463
2463
  replyToMessageId: number | null
2464
2464
  /** #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
2465
  resume?: { snapshot: CapturedDeliverySnapshot; hydrate: (ledger: BackstopDeliveryLedger, turnId: string) => void }
2466
- }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean; landedUnconfirmed: number }> {
2466
+ /** 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
2467
  const { chatId, turnId } = args
2468
2468
  // Spacers into `\n\n` gaps then split (as executeReply); a resume re-delivers the EXACT captured chunks (byte-stable, no re-split).
2469
2469
  const chunks = args.resume
@@ -2574,7 +2574,7 @@ async function deliverAnswer(args: {
2574
2574
  args.resume ? null : args.cardMessageId,
2575
2575
  {
2576
2576
  sendChunk,
2577
- readBack,
2577
+ readBack, onAckClaim: args.onAckClaim,
2578
2578
  recordOutbound: HISTORY_ENABLED
2579
2579
  ? (messageIds, texts) => {
2580
2580
  try {
@@ -9838,7 +9838,7 @@ function runDeliveryConfirmSweep(): void {
9838
9838
  const _deliveryConfirmSweep = isGatewayMain ? setInterval(runDeliveryConfirmSweep, DELIVERY_CONFIRM_SWEEP_MS) : undefined
9839
9839
  _deliveryConfirmSweep?.unref?.()
9840
9840
 
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)
9841
+ 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
9842
 
9843
9843
  // #1445 cross-turn pending-async ambient. When a turn ends after the
9844
9844
  // model dispatched background async work (Agent / Task / Bash run-in-
@@ -246,6 +246,15 @@ export interface HandbackPreturnSignal {
246
246
  * re-keyed to the real `statusKey` so the turn's end-of-turn
247
247
  * `clearActivitySummary` finalizes it. Cancels the debounce/self-reap. */
248
248
  tryAdopt: (turnId: string) => HandbackAdoption | null
249
+ /** True IFF a live (un-consumed) pre-turn entry is armed whose adopting turn
250
+ * is `turnId`. Consulted by the queued-card surface (stream-render Part B) at
251
+ * PARK time: when a handback pre-turn signal already owns the surface for the
252
+ * turn this parked message will mint, the queued card must NOT be posted —
253
+ * otherwise the same message ends up with two cards (the handback card the
254
+ * turn adopts, plus a queued card that then freezes). Turn-scoped, NOT
255
+ * key-scoped, so an unrelated user message parked on the same topic (a
256
+ * DIFFERENT adoptTurnId) is not wrongly suppressed. */
257
+ hasPendingForTurnId: (turnId: string) => boolean
249
258
  /** True IFF `turnKey` is a synthetic pre-turn record key. */
250
259
  isPreTurnRecord: (turnKey: string) => boolean
251
260
  /** Reap hook for the gateway's mid-session / boot card reapers: stop the
@@ -516,6 +525,13 @@ export function createHandbackPreturnSignal(
516
525
  return adoption
517
526
  },
518
527
 
528
+ hasPendingForTurnId(turnId) {
529
+ for (const e of byKey.values()) {
530
+ if (e.adoptTurnId === turnId && !e.consumed) return true
531
+ }
532
+ return false
533
+ },
534
+
519
535
  isPreTurnRecord(turnKey) {
520
536
  return turnKey.startsWith(PRETURN_TURNKEY_PREFIX)
521
537
  },
@@ -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)