switchroom 0.19.39 → 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 (31) 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 +524 -189
  5. package/dist/host-control/main.js +217 -5
  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/dist/bridge/bridge.js +8 -0
  11. package/telegram-plugin/dist/gateway/gateway.js +314 -191
  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/liveness-wiring.ts +15 -0
  17. package/telegram-plugin/gateway/outbound-send-path.ts +20 -0
  18. package/telegram-plugin/gateway/outbox-sweep.ts +116 -18
  19. package/telegram-plugin/gateway/silence-poke-session-event.ts +13 -0
  20. package/telegram-plugin/gateway/stream-render.ts +39 -1
  21. package/telegram-plugin/hooks/compaction-marker-precompact.mjs +70 -0
  22. package/telegram-plugin/hooks/hooks.json +11 -0
  23. package/telegram-plugin/session-tail.ts +20 -0
  24. package/telegram-plugin/silence-poke.ts +28 -0
  25. package/telegram-plugin/tests/outbox-delivery.test.ts +38 -1
  26. package/telegram-plugin/tests/outbox-flush-ack-claim-race.test.ts +213 -0
  27. package/telegram-plugin/tests/outbox-reply-then-recap-e2e.test.ts +1 -1
  28. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +4 -4
  29. package/telegram-plugin/tests/outbox-sweep-listen-button.test.ts +71 -8
  30. package/telegram-plugin/tests/send-reply-golden.test.ts +47 -0
  31. package/telegram-plugin/tests/silence-poke-compaction.test.ts +222 -0
@@ -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-
@@ -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
  {
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreCompact hook — marks "compaction in flight" for the gateway (#4058).
4
+ *
5
+ * Claude Code PreCompact protocol:
6
+ * Input: JSON on stdin — { session_id, transcript_path, cwd,
7
+ * hook_event_name: "PreCompact", trigger: "auto"|"manual",
8
+ * custom_instructions }
9
+ * Output: exit 0 + empty stdout → proceed. We NEVER block compaction and
10
+ * NEVER exit non-zero.
11
+ *
12
+ * Side effect: writes (overwrites) ONE small JSON file
13
+ * $TELEGRAM_STATE_DIR/compaction-in-flight.json
14
+ * with shape { ts, session_id, trigger }.
15
+ *
16
+ * Why: during mid-turn auto-compaction the model emits zero output and runs
17
+ * zero tools for minutes, so the gateway's silence-poke saw pure silence and
18
+ * fired its 300s "stalled turn" fallback on a healthy turn. The transcript
19
+ * only records compaction at its END (`system/compact_boundary` carries the
20
+ * compaction's own durationMs), so this hook is the one observable START
21
+ * signal. The gateway reads the marker's mtime (compaction-marker.ts) to
22
+ * DEFER the fallback — bounded by the same hard ceiling as the in-flight-tool
23
+ * defer — and removes it when the compact_boundary lands.
24
+ *
25
+ * If $TELEGRAM_STATE_DIR is unset → silent skip (dev / one-shot contexts;
26
+ * the fallback just keeps its pre-#4058 behaviour).
27
+ */
28
+
29
+ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+
32
+ function readStdin() {
33
+ try {
34
+ return readFileSync(0, 'utf8')
35
+ } catch {
36
+ return ''
37
+ }
38
+ }
39
+
40
+ function main() {
41
+ const stateDir = process.env.TELEGRAM_STATE_DIR
42
+ if (!stateDir) return
43
+
44
+ let payload = {}
45
+ try {
46
+ payload = JSON.parse(readStdin() || '{}')
47
+ } catch {
48
+ // Unparseable stdin — still write the marker: the mtime alone carries
49
+ // the load-bearing signal; the body fields are diagnostics.
50
+ }
51
+
52
+ try {
53
+ mkdirSync(stateDir, { recursive: true })
54
+ writeFileSync(
55
+ join(stateDir, 'compaction-in-flight.json'),
56
+ JSON.stringify({
57
+ ts: Date.now(),
58
+ session_id: typeof payload.session_id === 'string' ? payload.session_id : null,
59
+ trigger: typeof payload.trigger === 'string' ? payload.trigger : null,
60
+ }) + '\n',
61
+ { mode: 0o600 },
62
+ )
63
+ } catch {
64
+ // Best-effort: a missed marker just reverts one fallback decision to
65
+ // the pre-#4058 behaviour. Never fail the hook (never block compaction).
66
+ }
67
+ }
68
+
69
+ main()
70
+ process.exit(0)