switchroom 0.20.7 → 0.20.9

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 (52) hide show
  1. package/dist/agent-scheduler/index.js +111 -14
  2. package/dist/auth-broker/index.js +113 -30
  3. package/dist/cli/autoaccept-poll.js +5 -3
  4. package/dist/cli/drive-write-pretool.mjs +5 -3
  5. package/dist/cli/ms-365-write-pretool.mjs +5 -3
  6. package/dist/cli/notion-write-pretool.mjs +67 -6
  7. package/dist/cli/switchroom.js +389 -31
  8. package/dist/host-control/main.js +69 -8
  9. package/dist/vault/approvals/kernel-server.js +68 -7
  10. package/dist/vault/broker/server.js +68 -7
  11. package/package.json +1 -1
  12. package/profiles/default/CLAUDE.md.hbs +12 -13
  13. package/telegram-plugin/ask-user.ts +6 -7
  14. package/telegram-plugin/bridge/ipc-client.ts +17 -1
  15. package/telegram-plugin/dist/bridge/bridge.js +5 -2
  16. package/telegram-plugin/dist/gateway/gateway.js +410 -178
  17. package/telegram-plugin/dist/server.js +5 -2
  18. package/telegram-plugin/gateway/auth-broker-client.ts +1 -1
  19. package/telegram-plugin/gateway/auth-command.ts +4 -2
  20. package/telegram-plugin/gateway/boot-reason.ts +61 -0
  21. package/telegram-plugin/gateway/checklist-fallback.ts +8 -1
  22. package/telegram-plugin/gateway/cron-session.ts +66 -0
  23. package/telegram-plugin/gateway/gateway.ts +36 -34
  24. package/telegram-plugin/gateway/narrative-lane.ts +21 -1
  25. package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
  26. package/telegram-plugin/gateway/represent-delivery-guard.ts +33 -2
  27. package/telegram-plugin/gateway/stream-render.ts +11 -2
  28. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
  29. package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
  30. package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
  31. package/telegram-plugin/render/emphasis-guard.ts +92 -12
  32. package/telegram-plugin/render/line-start-guard.ts +27 -2
  33. package/telegram-plugin/sticker-aliases.ts +12 -14
  34. package/telegram-plugin/tests/ask-user.test.ts +15 -0
  35. package/telegram-plugin/tests/boot-card-reason.test.ts +88 -0
  36. package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
  37. package/telegram-plugin/tests/cron-bridge-drain-spool-ack.test.ts +150 -0
  38. package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
  39. package/telegram-plugin/tests/ipc-client-reconnect-rejection.test.ts +70 -0
  40. package/telegram-plugin/tests/narrative-lane-golden.test.ts +86 -0
  41. package/telegram-plugin/tests/queued-card-surface.test.ts +66 -0
  42. package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
  43. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
  44. package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
  45. package/telegram-plugin/tests/represent-guard.test.ts +45 -0
  46. package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
  47. package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
  48. package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
  49. package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
  50. package/telegram-plugin/tests/turn-flush-safety.test.ts +83 -0
  51. package/telegram-plugin/throttle-tier.ts +59 -0
  52. package/telegram-plugin/turn-flush-safety.ts +79 -0
@@ -24317,11 +24317,14 @@ function createIpcClient(options) {
24317
24317
  function scheduleReconnect() {
24318
24318
  if (closed)
24319
24319
  return;
24320
+ if (reconnectTimer)
24321
+ return;
24320
24322
  log(`reconnecting in ${currentDelay}ms`);
24321
24323
  reconnectTimer = setTimeout(() => {
24322
24324
  reconnectTimer = null;
24323
- if (!closed)
24324
- doConnect();
24325
+ if (!closed) {
24326
+ doConnect().catch(() => {});
24327
+ }
24325
24328
  }, currentDelay);
24326
24329
  currentDelay = Math.min(currentDelay * 2, maxReconnectDelayMs);
24327
24330
  }
@@ -28,7 +28,7 @@ export function createAuthBrokerClient(): {
28
28
  listState: () => broker.listState(),
29
29
  setActive: (label: string) => broker.setActive(label),
30
30
  markExhausted: (until?: number) => broker.markExhausted(until),
31
- markThrottled: (until: number) => broker.markThrottled(until),
31
+ markThrottled: (until: number, probeOnly?: boolean) => broker.markThrottled(until, probeOnly),
32
32
  rmAccount: (label: string) => broker.rmAccount(label),
33
33
  refreshAccount: (label: string) => broker.refreshAccount(label),
34
34
  setOverride: (agent: string, account: string | null) =>
@@ -334,10 +334,12 @@ export interface AuthBrokerClient {
334
334
  * per-account rate limit on the CALLER's own account — `throttled_until`
335
335
  * in the quota ledger — WITHOUT rolling the fleet and WITHOUT touching
336
336
  * eligibility. `escalated` is true when the broker's escalation guard
337
- * (repeated hits corroborated by a live probe) converted it into the
337
+ * (a first-hit live probe corroborating a wall) converted it into the
338
338
  * standard mark-exhausted + roll; `rolledTo` names the roll target then.
339
+ * `probeOnly` (#failover-429-corroborate, generic-transient origin): run ONLY
340
+ * the escalation probe — a healthy probe records nothing (account stays inert).
339
341
  */
340
- markThrottled(until: number): Promise<{
342
+ markThrottled(until: number, probeOnly?: boolean): Promise<{
341
343
  account: string
342
344
  throttled_until: number
343
345
  escalated: boolean
@@ -83,3 +83,64 @@ export function determineRestartReason(opts: {
83
83
  if (sessionMarker != null) return 'crash'
84
84
  return 'fresh'
85
85
  }
86
+
87
+ /**
88
+ * Boot-reason window during which a bridge re-register reuses the reason
89
+ * the boot path already determined. Matches the restart-marker and
90
+ * operator-marker freshness windows (5 min) — a bridge that survived a
91
+ * gateway restart reconnects within seconds of the new gateway's boot,
92
+ * comfortably inside it.
93
+ */
94
+ export const BOOT_REASON_REUSE_WINDOW_MS = 5 * 60_000
95
+
96
+ /**
97
+ * Determine the restart reason for a BRIDGE RE-REGISTER (gateway.ts
98
+ * `onClientRegistered`, the `bridge-reconnect` path) — as opposed to the
99
+ * gateway's own boot path.
100
+ *
101
+ * Why this exists (fleet-audit B2, kdogg 2026-08-02 06:27 trace): on a
102
+ * planned gateway restart (`cli: restart` SIGTERM), the bridge — living
103
+ * inside the separate claude process — survives and reconnects a few
104
+ * seconds after the new gateway boots. By then the boot path has already
105
+ * READ AND CLEARED the restart / clean-shutdown markers (the 2026-05-25
106
+ * GC, gateway.ts boot path), so re-deriving the reason from disk falls
107
+ * through to the sessionMarker branch and every such re-register logs —
108
+ * and, when a chat is resolvable, POSTS a boot card claiming —
109
+ * `reason=crash` for a perfectly graceful restart. Hundreds of these per
110
+ * agent fleet-wide (lawgpt 310, reggie 179, ziggy 175, kdogg 174).
111
+ *
112
+ * Decision:
113
+ * 1. Any on-disk marker still present → normal `determineRestartReason`
114
+ * (in-gateway /restart flows where the gateway never went down write
115
+ * a marker the boot path never consumed — keep honoring it).
116
+ * 2. No markers, but the gateway booted recently (<5 min) and recorded
117
+ * the reason it determined at boot → reuse that reason. The bridge
118
+ * is re-registering into the SAME restart episode the boot path
119
+ * already classified.
120
+ * 3. Otherwise (gateway long-lived, markers absent) → fall through to
121
+ * `determineRestartReason` — a marker-less bridge re-register hours
122
+ * into a gateway's life still classifies conservatively as 'crash'
123
+ * (the claude/bridge side genuinely died and came back).
124
+ */
125
+ export function determineBridgeReconnectReason(opts: {
126
+ marker: { ts: number } | null
127
+ cleanMarker: CleanShutdownMarker | null
128
+ sessionMarker: SessionMarker | null
129
+ now: number
130
+ /** `GATEWAY_STARTED_AT_MS` of the running gateway process. */
131
+ gatewayStartedAtMs: number
132
+ /** Reason the gateway's own boot path determined (null if it never ran). */
133
+ bootReason: RestartReason | null
134
+ bootReasonReuseWindowMs?: number
135
+ cleanMaxAgeMs?: number
136
+ markerMaxAgeMs?: number
137
+ operatorMaxAgeMs?: number
138
+ }): RestartReason {
139
+ const { gatewayStartedAtMs, bootReason, bootReasonReuseWindowMs = BOOT_REASON_REUSE_WINDOW_MS } = opts
140
+ if (opts.marker == null && opts.cleanMarker == null
141
+ && bootReason != null
142
+ && opts.now - gatewayStartedAtMs < bootReasonReuseWindowMs) {
143
+ return bootReason
144
+ }
145
+ return determineRestartReason(opts)
146
+ }
@@ -33,6 +33,8 @@
33
33
  * (same pattern as checklist-message-handler.ts).
34
34
  */
35
35
 
36
+ import { parseSourceMessageId } from './source-message-id.js'
37
+
36
38
  export interface ChecklistTaskState {
37
39
  /** 1-based sequential id assigned at send time (mirrors the native API's
38
40
  * required per-task integer id; doubles as the patch handle in text mode). */
@@ -130,6 +132,11 @@ export function buildNativeChecklistPayload(p: {
130
132
  replyToMessageId?: number
131
133
  protectContent?: boolean
132
134
  }): Record<string, unknown> {
135
+ // #4368 — defense at the payload boundary: a fabricated / out-of-int32
136
+ // reply anchor is dropped here so the native checklist sends UNANCHORED
137
+ // rather than Telegram 400ing on `reply_parameters.message_id`. Independent
138
+ // of any caller-side guard so this builder can never emit a bad anchor.
139
+ const replyAnchor = parseSourceMessageId(p.replyToMessageId)
133
140
  return {
134
141
  business_connection_id: p.businessConnectionId,
135
142
  chat_id: p.chatId,
@@ -137,7 +144,7 @@ export function buildNativeChecklistPayload(p: {
137
144
  title: p.title,
138
145
  tasks: p.tasks.map((t) => ({ id: t.id, text: t.text })),
139
146
  },
140
- ...(p.replyToMessageId != null ? { reply_parameters: { message_id: p.replyToMessageId } } : {}),
147
+ ...(replyAnchor != null ? { reply_parameters: { message_id: replyAnchor } } : {}),
141
148
  ...(p.protectContent === true ? { protect_content: true } : {}),
142
149
  }
143
150
  }
@@ -17,6 +17,10 @@
17
17
  * target via `cronIdentity()`. Pure string fns — pinned in cron-session.test.ts.
18
18
  */
19
19
 
20
+ import type { InboundMessage, GatewayToClient } from './ipc-protocol.js'
21
+ import type { InboundSpool } from './inbound-spool.js'
22
+ import { redeliverBufferedInbound, type PendingInboundBuffer } from './pending-inbound-buffer.js'
23
+
20
24
  /** Suffix that distinguishes a cron-session bridge from the main agent bridge. */
21
25
  export const CRON_IDENTITY_SUFFIX = "-cron";
22
26
 
@@ -140,3 +144,65 @@ export function deliverInjectWithFallback(
140
144
  }
141
145
  return { target, delivered: false, fellBackToMain: false };
142
146
  }
147
+
148
+ /** Minimal view of the IPC client the cron-bridge register handler needs.
149
+ * A structural subset of `IpcClient` so this stays unit-testable without the
150
+ * gateway's module-load side effects. */
151
+ export interface CronBridgeRegisterClient {
152
+ agentName: string | null | undefined;
153
+ send: (msg: GatewayToClient) => void;
154
+ }
155
+
156
+ /**
157
+ * Drain a cheap-cron (`<agent>-cron`) bridge's buffered fires when it
158
+ * registers — the Tier-1 §2.4/§3.3 status-silent path (#4348).
159
+ *
160
+ * The cron bridge is STATUS-SILENT: it must NOT drive the gateway's singleton
161
+ * machinery (shadow bridge-state, warmup, boot card). But it MUST still flush
162
+ * any cron fire buffered+spooled during the boot window — a due tick that
163
+ * arrived before the cron bridge registered.
164
+ *
165
+ * THE BUG (#4348): the pre-fix path did a raw `pendingInboundBuffer.drain()` +
166
+ * `client.send()` loop and returned early WITHOUT ever reaching `spool.ack`.
167
+ * `spool.ack` lives ONLY inside `redeliverBufferedInbound` — the one chokepoint
168
+ * every other drain path (bridgeUp, idle-drain, silence-poke, turn-end) routes
169
+ * through. So the durable spool entry stayed live, boot-replay re-pushed it on
170
+ * the next restart, and the SAME cron fire re-fired: a duplicate delivery
171
+ * bounded only by the 15-min escalation sweep. This was the ONLY drain that
172
+ * bypassed the chokepoint.
173
+ *
174
+ * THE FIX: route the drain through `redeliverBufferedInbound` too, so each
175
+ * delivered fire is spool-acked exactly once and cannot re-fire after a
176
+ * restart. `beforeRedeliver` (the represent-veto) rides along by construction;
177
+ * it self-gates and is a no-op for cron-sourced fires. A `send` throw now
178
+ * re-buffers the fire (lossless) instead of dropping it — strictly safer than
179
+ * the pre-fix best-effort drop and identical to every sibling drain path.
180
+ *
181
+ * Returns the `redeliverBufferedInbound` counts for observability.
182
+ */
183
+ export function drainCronBridgeOnRegister(
184
+ client: CronBridgeRegisterClient,
185
+ buffer: PendingInboundBuffer,
186
+ spool?: InboundSpool,
187
+ log?: (line: string) => void,
188
+ ): { drained: number; redelivered: number; rebuffered: number; retracted: number } {
189
+ // Status-silent handshake ack (unchanged from the pre-fix path).
190
+ client.send({ type: "status", status: "agent_connected" });
191
+ const send = (msg: InboundMessage): boolean => {
192
+ try {
193
+ client.send(msg);
194
+ return true;
195
+ } catch {
196
+ return false;
197
+ }
198
+ };
199
+ const result = redeliverBufferedInbound(buffer, client.agentName ?? "", send, spool);
200
+ if (result.drained > 0 && log != null) {
201
+ log(
202
+ `telegram gateway: cron-bridge drain agent=${client.agentName} ` +
203
+ `drained=${result.drained} redelivered=${result.redelivered} ` +
204
+ `rebuffered=${result.rebuffered}\n`,
205
+ );
206
+ }
207
+ return result;
208
+ }
@@ -427,6 +427,7 @@ import {
427
427
  build429ClassifiedMetric,
428
428
  classify429Detail,
429
429
  decideThrottleTier,
430
+ routeRateLimit429,
430
431
  throttleRetryInPlaceMaxMs,
431
432
  } from '../throttle-tier.js'
432
433
  import { createThrottleTierRunner } from './throttle-tier-wiring.js'
@@ -497,7 +498,7 @@ import {
497
498
  type ReplyOwnerTier, type ReplyOwnerCandidates,
498
499
  type AnswerDeliveredLatch,
499
500
  } from '../reply-owner-resolve.js'
500
- import { SubagentHandbackMarker } from './subagent-handback-marker.js'
501
+ import { SubagentHandbackMarker, cliTaskNotifLedger } from './subagent-handback-marker.js'
501
502
  // PR A — deterministic answer-ready quiescence flush (late-delivery fix).
502
503
  import { AnswerReadyFlushController, resolveAnswerReadyFlushMs, resolveAnswerStageMs } from '../answer-ready-flush.js'
503
504
  // #1667 — pure decision core for the turn_end answer-delivery gate (#1664).
@@ -660,7 +661,7 @@ import { handleRequestMs365Approval } from './ms365-write-approval.js'
660
661
  import { buildDiffPreviewCard } from './diff-preview-card.js'
661
662
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
662
663
  import { makeRepresentRedeliveryGuard, makeSessionBusyDrainDeferral } from './represent-delivery-guard.js'
663
- import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession } from './cron-session.js'
664
+ import { isCronIdentity, isCronInjectFire, deliverInjectWithFallback, replyCallerIsForeignSession, drainCronBridgeOnRegister } from './cron-session.js'
664
665
  import {
665
666
  ObligationLedger,
666
667
  obligationEscalationText,
@@ -857,7 +858,7 @@ import {
857
858
  resolvePersonaName, shouldSkipDuplicateBootCard,
858
859
  type BootCardHandle, type RestartReason,
859
860
  } from './boot-card.js'
860
- import { determineRestartReason } from './boot-reason.js'
861
+ import { determineRestartReason, determineBridgeReconnectReason } from './boot-reason.js'
861
862
  import { maybeRenderUpdateAnnouncement } from './update-announce.js'
862
863
  import { createIssuesCardHandle, type IssuesCardHandle } from '../issues-card.js'
863
864
  import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.js'
@@ -7827,6 +7828,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
7827
7828
  agent,
7828
7829
  shouldEmitCard: (a) => shouldEmitOperatorEvent(a, 'rate-limited'),
7829
7830
  })
7831
+ routeRateLimit429(rateLimit429Classification, throttleTierRunner, agent) // #failover-429-corroborate probe-only seam; boolean return is for test observability (throttle-tier-route-429.test.ts), intentionally discarded here — see docstring
7830
7832
  if (surface === 'litellm-local-notice') {
7831
7833
  process.stderr.write(
7832
7834
  `telegram gateway: 429 classified litellm-proxy-local agent=${agent} — ` +
@@ -9163,6 +9165,8 @@ let activeBootCard: BootCardHandle | null = null
9163
9165
  // dedupe checks this so it can't race against the boot path's await.
9164
9166
  // See issue #489 (klanker msgId 4715 + 4716, 2026-05-01 10:13:15).
9165
9167
  let bootCardPending = false
9168
+ // Boot-determined restart reason — see determineBridgeReconnectReason (B2).
9169
+ let bootReasonAtStartup: RestartReason | null = null
9166
9170
 
9167
9171
  // Issues card (#428) — pinned per-agent surface listing current
9168
9172
  // unresolved entries from the issue sink (#425). Idempotent across
@@ -10366,17 +10370,13 @@ if (isGatewayMain) ipcServer = createIpcServer({
10366
10370
 
10367
10371
  onClientRegistered(client: IpcClient) {
10368
10372
  process.stderr.write(`telegram gateway: bridge registered — agent=${client.agentName}\n`)
10369
- // Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap
10370
- // session. It is STATUS-SILENT — it must NOT drive the gateway's
10371
- // singleton machinery (shadow bridge-state, warmup, boot card, which all
10372
- // track the MAIN agent's liveness). Drain any buffered cron fire to it
10373
- // (so a fire that triggered a lazy spawn lands), then return early.
10373
+ // Cheap-cron (§2.4/§3.3): a `<agent>-cron` bridge is the Tier-1 cheap,
10374
+ // STATUS-SILENT session — it must NOT drive the gateway's singleton
10375
+ // machinery. Drain any boot-window cron fire to it, then return early.
10376
+ // #4348: the drain routes through `redeliverBufferedInbound` (the shared
10377
+ // ack chokepoint) so a spooled fire is acked and never re-fires on restart.
10374
10378
  if (isCronIdentity(client.agentName)) {
10375
- client.send({ type: 'status', status: 'agent_connected' })
10376
- const pending = pendingInboundBuffer.drain(client.agentName ?? '')
10377
- for (const m of pending) {
10378
- try { client.send(m) } catch { /* cron fire drop — best-effort, like today's cron */ }
10379
- }
10379
+ drainCronBridgeOnRegister(client, pendingInboundBuffer, inboundSpool ?? undefined, (l) => process.stderr.write(l))
10380
10380
  return
10381
10381
  }
10382
10382
  // Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
@@ -10450,15 +10450,12 @@ if (isGatewayMain) ipcServer = createIpcServer({
10450
10450
  })
10451
10451
  }
10452
10452
 
10453
- // If the agent reconnected after a /restart (or any restart), post a boot
10454
- // card. The restart-marker carries the ack chat; if absent we fall back to
10455
- // resolveBootChatId so crash-recovery reconnects also get a card.
10456
- //
10457
- // Skip if the boot path already posted a card OR is currently in-flight
10458
- // on its sendMessage await (issue #489 — klanker msgId 4715+4716,
10459
- // 2026-05-01). The original dedupe (msgId 2245+2248, 2026-04-26) only
10460
- // covered the post-resolution case; bootCardPending closes the in-flight
10461
- // race window. See `shouldSkipDuplicateBootCard`.
10453
+ // If the agent reconnected after a restart, post a boot card. The
10454
+ // restart-marker carries the ack chat; else fall back to resolveBootChatId
10455
+ // so crash-recovery reconnects also get a card. Skip if the boot path
10456
+ // already posted a card OR its sendMessage is in-flight (issue #489,
10457
+ // klanker 2026-05-01; earlier dedupe 2026-04-26 only covered the
10458
+ // post-resolution case). See `shouldSkipDuplicateBootCard`.
10462
10459
  const dedupeDecision = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, 'bridge-reconnect')
10463
10460
  if (dedupeDecision.skip) {
10464
10461
  process.stderr.write(`telegram gateway: bridge-reconnect: skipping boot card (${dedupeDecision.reason})\n`)
@@ -10475,7 +10472,9 @@ if (isGatewayMain) ipcServer = createIpcServer({
10475
10472
  clearRestartMarker()
10476
10473
  }
10477
10474
 
10478
- const reason = determineRestartReason({ marker, cleanMarker, sessionMarker: storedSession, now: nowMs })
10475
+ // B2: boot path cleared the markers reuse its reason (boot-reason.ts).
10476
+ const reason = determineBridgeReconnectReason({ marker, cleanMarker, sessionMarker: storedSession,
10477
+ now: nowMs, gatewayStartedAtMs: GATEWAY_STARTED_AT_MS, bootReason: bootReasonAtStartup })
10479
10478
  const target = resolveBootChatId(marker, markerAgeMs)
10480
10479
 
10481
10480
  if (target) {
@@ -10693,6 +10692,7 @@ if (isGatewayMain) ipcServer = createIpcServer({
10693
10692
  }
10694
10693
  }
10695
10694
  const ev = msg.event as unknown as SessionEvent
10695
+ if (ev.kind === 'task_notification') cliTaskNotifLedger.record(ev.taskId, ev.status, Date.now()) // double-wake dedup — ledger doc in subagent-handback-marker.ts
10696
10696
  // #1122/#1126: session events used to be ingested into the pinned progress
10697
10697
  // card here (`progressDriver.ingest`). The card is retired and the driver
10698
10698
  // is permanently null, so that call was a dead no-op — removed. Session
@@ -11760,7 +11760,9 @@ const IDLE_DRAIN_INTERVAL_MS = 5000
11760
11760
  // while the CLI is still producing the answer; draining a represent into that busy
11761
11761
  // session re-queues it BEHIND the real reply → a duplicate. Defer while busy,
11762
11762
  // bounded so a wedged session still drains (F1 then retracts it post-reply).
11763
- const idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS)
11763
+ // staleGap = 3 poll intervals: a longer gap between busy consultations means the
11764
+ // buffer drained via a non-idle path so the next one is a NEW episode (#4341 f/u).
11765
+ const idleDrainBusyDefer = makeSessionBusyDrainDeferral(OBLIGATION_BACKGROUND_WORK_GRACE_MS, IDLE_DRAIN_INTERVAL_MS * 3)
11764
11766
  if (isGatewayMain && !STATIC) {
11765
11767
  setInterval(() => {
11766
11768
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
@@ -11918,7 +11920,7 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
11918
11920
  const tasks = args.tasks as Array<{ text: string; done?: boolean }> | undefined
11919
11921
  if (!Array.isArray(tasks) || tasks.length === 0) throw new Error('send_checklist: tasks must be a non-empty array')
11920
11922
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
11921
- const replyTo = args.reply_to != null ? Number(args.reply_to) : undefined
11923
+ const replyTo = parseSourceMessageId(args.reply_to as string | number | null | undefined) ?? undefined // #4368: drop a fabricated/out-of-int32 anchor so the send lands unanchored, not 400
11922
11924
  const protectContent = args.protect_content === true
11923
11925
 
11924
11926
  assertAllowedChat(chat_id)
@@ -23490,15 +23492,14 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
23490
23492
  } else {
23491
23493
  const markerAgeMs = marker ? nowMs - marker.ts : undefined
23492
23494
  const reason = determineRestartReason({ marker, cleanMarker, sessionMarker: storedSession, now: nowMs })
23495
+ // Markers were cleared above — stash for bridge re-register (B2).
23496
+ bootReasonAtStartup = reason
23493
23497
  const target = resolveBootChatId(marker, markerAgeMs)
23494
23498
 
23495
- // Issue #92: when reason='crash' AND no chat is resolvable,
23496
- // the gateway used to silently skip the only signal a user
23497
- // got was their next message landing on a fresh process. Now
23498
- // we always surface unplanned crashes via the operator-events
23499
- // pipeline, which broadcasts to access.allowFrom (same path
23500
- // permission requests use). The pipeline's per-agent per-kind
23501
- // cooldown protects against crash loops spamming the chat.
23499
+ // Issue #92: when reason='crash' AND no chat is resolvable, the
23500
+ // gateway used to silently skip. Always surface unplanned crashes
23501
+ // via the operator-events pipeline (broadcasts to access.allowFrom;
23502
+ // its per-agent per-kind cooldown absorbs crash loops).
23502
23503
  if (reason === 'crash') {
23503
23504
  const cleanMarkerStale = cleanMarker
23504
23505
  ? !shouldSuppressRecoveryBanner(cleanMarker, nowMs, CLEAN_SHUTDOWN_MAX_AGE_MS)
@@ -24372,11 +24373,12 @@ async function startGateway(): Promise<void> { // #2996 P0c: the boot IIFE, now
24372
24373
  // deterministic dedup key — closes the #1719
24373
24374
  // re-fire-on-restart class.
24374
24375
  jsonlAgentId: agentId,
24376
+ cliTaskNotificationSeen: cliTaskNotifLedger.seenRecently(agentId, Date.now()), // double-wake dedup, fail-open — ledger doc in subagent-handback-marker.ts
24375
24377
  })
24376
24378
  if (!decision.deliver) {
24377
- if (decision.reason === 'no-chat') {
24379
+ if (decision.reason === 'no-chat' || decision.reason === 'cli-task-notification') {
24378
24380
  process.stderr.write(
24379
- `telegram gateway: subagent-handback ${agentId} — no chat to deliver to; skipped\n`,
24381
+ `telegram gateway: subagent-handback ${agentId} skipped ${decision.reason === 'no-chat' ? 'no chat to deliver to' : 'CLI task-notification already woke the parent for this completion (double-wake dedup)'}\n`,
24380
24382
  )
24381
24383
  }
24382
24384
  return
@@ -56,6 +56,7 @@ import {
56
56
  appendActivityLabel, clipNarrative, formatStepSuffix, renderActivityFeedWithNested,
57
57
  } from '../tool-activity-summary.js'
58
58
  import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
59
+ import { isSilentSentinelCardOutcome } from '../turn-flush-safety.js'
59
60
  import { clearActivityCardRecord, writeActivityCardRecord } from './activity-card-store.js'
60
61
  import { chatKeyWithSuffix } from './chat-key.js'
61
62
  import {
@@ -880,7 +881,26 @@ export function createNarrativeLane(deps: NarrativeLaneDeps) {
880
881
  // before the delete. turn-end stays the idempotent backstop: it no-ops
881
882
  // once nothing is claimed for the key.
882
883
  await reconcileStatusPin(`fg:${statusKey(chat, thread)}`, chat, { pinned: false })
883
- if (CLEAR_STATUS_ON_COMPLETION) {
884
+ // #4348 — silent-sentinel suppression. A turn whose entire user-facing
885
+ // outcome is a bare NO_REPLY / HEARTBEAT_OK (the sentinel-reply-guard
886
+ // dropped a sentinel-only reply, or the flush safety net classified the
887
+ // captured text as silent) said nothing to the user, so its activity/
888
+ // telemetry card is pure noise — most visibly on the forced-synthesis
889
+ // handback turns a background sub-agent injects. DELETE the card rather
890
+ // than finalizing a visible `done · … · ✓ NO_REPLY` record. Deterministic
891
+ // (reuses the shared `turn-flush-safety` silent predicates, no model
892
+ // behaviour) and normal-case-safe (`finalAnswerEverDelivered` keeps every
893
+ // card for a turn that actually delivered a substantive answer). The
894
+ // `finalHtmlOverride` finalize path is only taken by the foreground
895
+ // handoff-clear, which fires on a delivered final answer — so this gate
896
+ // never contends with it.
897
+ const silentSentinelTurn = isSilentSentinelCardOutcome({
898
+ replyCalled: turn.replyCalled,
899
+ lastReplyText: turn.lastReplyText,
900
+ capturedText: turn.capturedText,
901
+ finalAnswerEverDelivered: turn.finalAnswerEverDelivered,
902
+ })
903
+ if (CLEAR_STATUS_ON_COMPLETION || silentSentinelTurn) {
884
904
  try {
885
905
  await robustApiCall(
886
906
  () => bot.api.deleteMessage(chat, id),
@@ -73,6 +73,7 @@ import { getBuzzMirror } from './buzz-mirror.js'
73
73
  import { isFinalAnswerReply, isSubstantiveFinalReply, shouldJournalReplySiteDelivery } from '../final-answer-detect.js'
74
74
  import { decideOverPing, type OverPingDecision } from '../over-ping-safety-net.js'
75
75
  import { decideSilentReplyAnchor } from '../silent-reply-anchor.js'
76
+ import { parseSourceMessageId } from './source-message-id.js'
76
77
  import {
77
78
  decideSupersedeCorrection,
78
79
  flushedAnswerMatchesReply,
@@ -1343,7 +1344,14 @@ export async function sendReply(
1343
1344
 
1344
1345
  const files = (args.files as string[] | undefined) ?? []
1345
1346
  const quoteOptIn = args.quote !== false
1346
- let reply_to = args.reply_to != null ? Number(args.reply_to) : undefined
1347
+ // #4368 the model's reply tool can quote a synthetic inbound (a boot-
1348
+ // resume/handback/cron fabricated id at `Date.now()` scale). Route it through
1349
+ // the canonical guard so an out-of-int32 anchor is DROPPED (send lands
1350
+ // unanchored) rather than 400ing every chunk on `reply_parameters.message_id`.
1351
+ // A later `reply_to = latest` (quote-opt-in default) is a Telegram-returned
1352
+ // id and needs no re-check. Also closes the pre-existing NaN hole: a non-
1353
+ // numeric `reply_to` used to coerce to NaN and still build the anchor.
1354
+ let reply_to = parseSourceMessageId(args.reply_to as string | number | null | undefined) ?? undefined
1347
1355
  const protectContent = args.protect_content === true
1348
1356
  const quoteText = args.quote_text as string | undefined
1349
1357
  const access = loadAccess()
@@ -137,19 +137,50 @@ export function makeRepresentRedeliveryGuard(
137
137
  * ordinary busy turn (which ends well within the bound) defers cleanly and never
138
138
  * consumes the wedge budget.
139
139
  *
140
+ * Episode isolation (#4341 follow-up): `deferringSince` used to reset ONLY on a
141
+ * `busy=false` call, but the predicate is not consulted on every drain path. When
142
+ * a buffer is emptied by a bridge re-register (`onClientRegistered`) while the
143
+ * session is still busy, the idle-drain gate is never invoked with `busy=false`,
144
+ * so `deferringSince` stays pinned at the old t0. A later, UNRELATED deferral
145
+ * episode then computes `now - t0 >= boundMs` immediately and drains a represent
146
+ * into a mid-answer session — reopening the duplicate window this guard closes.
147
+ * Fix: a fresh deferral episode also starts the clock at `now` when the predicate
148
+ * has not been consulted within `staleGapMs`. The idle-drain gate polls on a
149
+ * fixed short interval while (and only while) the buffer is non-empty, so a gap
150
+ * between consecutive consultations longer than a few poll intervals means the
151
+ * prior episode's buffer drained via a non-idle path and this is a new episode.
152
+ *
140
153
  * `boundMs <= 0` disables the busy-defer entirely (kill switch / parity with the
141
- * background-work grace being disabled).
154
+ * background-work grace being disabled). `staleGapMs <= 0` disables the
155
+ * gap-based episode reset (leaving only the `busy=false` reset).
142
156
  */
157
+ export const DEFAULT_DRAIN_DEFER_STALE_GAP_MS = 15_000
158
+
143
159
  export function makeSessionBusyDrainDeferral(
144
160
  boundMs: number,
161
+ staleGapMs: number = DEFAULT_DRAIN_DEFER_STALE_GAP_MS,
145
162
  ): (busy: boolean, now: number) => boolean {
146
163
  let deferringSince: number | null = null
164
+ let lastCallAt: number | null = null
147
165
  return (busy, now) => {
166
+ const gapSinceLastCall = lastCallAt == null ? null : now - lastCallAt
167
+ lastCallAt = now
148
168
  if (!busy || boundMs <= 0) {
149
169
  deferringSince = null
150
170
  return false
151
171
  }
152
- if (deferringSince == null) deferringSince = now
172
+ // Start (or restart) the clock at the top of a NEW deferral episode:
173
+ // - we were not deferring (deferringSince cleared by a busy=false call), OR
174
+ // - the predicate has not been consulted within staleGapMs, which means the
175
+ // prior episode's buffer drained via a path that never calls us with
176
+ // busy=false (bridge re-register) and left deferringSince pinned at a
177
+ // stale t0. Either way a fresh episode's bound must run from `now`.
178
+ if (
179
+ deferringSince == null ||
180
+ (staleGapMs > 0 && gapSinceLastCall != null && gapSinceLastCall > staleGapMs)
181
+ ) {
182
+ deferringSince = now
183
+ }
153
184
  // Bounded: stop deferring once we have been busy past the ceiling, so a
154
185
  // wedged/hung session still eventually drains the buffered represent.
155
186
  return now - deferringSince < boundMs
@@ -1138,8 +1138,17 @@ export function handleSessionEvent(deps: StreamRenderDeps, ev: SessionEvent): vo
1138
1138
  }
1139
1139
  if (QUEUED_CARD_ENABLED && !handbackOwnsSurface) {
1140
1140
  const cardChatId = ev.chatId
1141
- const replyToRaw = ev.messageId != null ? Number(ev.messageId) : null
1142
- const replyTo = replyToRaw != null && Number.isFinite(replyToRaw) ? replyToRaw : null
1141
+ // Reply-anchor ONLY to a plausible real Telegram message id. Synthetic
1142
+ // enqueues (subagent handback, boot resume, cron) fabricate `messageId`
1143
+ // from `Date.now()` (~1.78e13) — finite, so a bare `Number.isFinite`
1144
+ // guard passes it, but `reply_parameters.message_id` hard-rejects
1145
+ // anything beyond signed int32 with 400 `field "message_id" must be a
1146
+ // valid Number` (`allow_sending_without_reply` does NOT bypass the
1147
+ // range check), killing the whole card send (overlord
1148
+ // gateway-supervisor.log 2026-08-04, e.g. msg=1785846295635). Same bug
1149
+ // class as the resume-dark-feed incident — reuse its guard, don't
1150
+ // re-derive a weaker one. An unanchored card is fine; no card is not.
1151
+ const replyTo = parseSourceMessageId(ev.messageId)
1143
1152
  void openQueuedCard(deps, cardChatId, enqThreadIdNum ?? null, replyTo).then((cardId) => {
1144
1153
  if (cardId == null) return
1145
1154
  // Still parked → adopt on the next dequeue. Otherwise the entry already
@@ -183,6 +183,17 @@ export interface SubagentHandbackDecisionInput {
183
183
  * the built inbound's `meta.subagent_jsonl_id`. See
184
184
  * `SubagentHandbackContext.jsonlAgentId` for the dedup rationale. */
185
185
  jsonlAgentId?: string
186
+ /**
187
+ * Double-wake dedup (v0.20.8 candidate): true iff the session tail already
188
+ * observed a TERMINAL CLI `<task-notification>` for EXACTLY this sub-agent's
189
+ * task id within `TASK_NOTIFICATION_DEDUP_TTL_MS` — i.e. the CLI itself has
190
+ * already woken (or queued the wake of) the parent with this completion, so
191
+ * a second gateway-synthesized wake would duplicate it. The caller resolves
192
+ * this from `CliTaskNotificationLedger.seenRecently(agentId, now)`
193
+ * (subagent-handback-marker.ts). FAIL-OPEN: omitted/false → deliver; only a
194
+ * confirmed exact-id in-window hit suppresses.
195
+ */
196
+ cliTaskNotificationSeen?: boolean
186
197
  /** Deterministic clock for tests. */
187
198
  nowMs?: number
188
199
  }
@@ -192,6 +203,7 @@ export type SubagentHandbackSkipReason =
192
203
  | 'env-disabled'
193
204
  | 'outcome-not-terminal'
194
205
  | 'foreground'
206
+ | 'cli-task-notification'
195
207
  | 'no-chat'
196
208
 
197
209
  export type SubagentHandbackDecision =
@@ -208,7 +220,12 @@ export type SubagentHandbackDecision =
208
220
  * stale historical-at-boot row, not a fresh completion.
209
221
  * 3. foreground — a foreground sub-agent already handed its result
210
222
  * back as the Task tool result in the parent's own turn.
211
- * 4. no-chatneither the fleet entry nor the owner chat resolved,
223
+ * 4. cli-task-notification — the claude CLI's OWN `<task-notification>`
224
+ * for exactly this task id was already observed in-window, so the CLI
225
+ * has already woken the parent with this completion; a second
226
+ * gateway-synthesized wake would double it (the double-message bug).
227
+ * Fail-open: only a confirmed exact-id hit skips.
228
+ * 5. no-chat — neither the fleet entry nor the owner chat resolved,
212
229
  * so there is nowhere to deliver.
213
230
  */
214
231
  export function decideSubagentHandback(
@@ -223,6 +240,9 @@ export function decideSubagentHandback(
223
240
  if (!input.isBackground) {
224
241
  return { deliver: false, reason: 'foreground' }
225
242
  }
243
+ if (input.cliTaskNotificationSeen === true) {
244
+ return { deliver: false, reason: 'cli-task-notification' }
245
+ }
226
246
  const chatId = input.fleetChatId || input.ownerChatId
227
247
  if (!chatId) {
228
248
  return { deliver: false, reason: 'no-chat' }