switchroom 0.20.8 → 0.20.10

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 (43) hide show
  1. package/bin/handoff-briefing.sh +57 -5
  2. package/bin/working-state-reload-hook.sh +262 -0
  3. package/dist/agent-scheduler/index.js +16 -13
  4. package/dist/auth-broker/index.js +70 -30
  5. package/dist/cli/autoaccept-poll.js +5 -3
  6. package/dist/cli/drive-write-pretool.mjs +5 -3
  7. package/dist/cli/ms-365-write-pretool.mjs +5 -3
  8. package/dist/cli/notion-write-pretool.mjs +6 -6
  9. package/dist/cli/switchroom.js +42 -13
  10. package/dist/host-control/main.js +7 -7
  11. package/dist/vault/approvals/kernel-server.js +6 -6
  12. package/dist/vault/broker/server.js +6 -6
  13. package/package.json +1 -1
  14. package/profiles/_base/start.sh.hbs +49 -0
  15. package/profiles/default/CLAUDE.md.hbs +12 -13
  16. package/telegram-plugin/ask-user.ts +6 -7
  17. package/telegram-plugin/dist/gateway/gateway.js +192 -66
  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/checklist-fallback.ts +8 -1
  21. package/telegram-plugin/gateway/gateway.ts +8 -4
  22. package/telegram-plugin/gateway/outbound-send-path.ts +9 -1
  23. package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +21 -1
  24. package/telegram-plugin/gateway/subagent-handback-marker.ts +94 -0
  25. package/telegram-plugin/gateway/throttle-tier-wiring.ts +93 -18
  26. package/telegram-plugin/render/emphasis-guard.ts +92 -12
  27. package/telegram-plugin/render/line-start-guard.ts +27 -2
  28. package/telegram-plugin/sticker-aliases.ts +12 -14
  29. package/telegram-plugin/tests/ask-user.test.ts +15 -0
  30. package/telegram-plugin/tests/checklist-fallback.test.ts +21 -0
  31. package/telegram-plugin/tests/handback-tasknotif-dedup.test.ts +248 -0
  32. package/telegram-plugin/tests/render/emphasis-guard.test.ts +105 -6
  33. package/telegram-plugin/tests/render/heading-guard-blockquote-glued-hash.test.ts +123 -36
  34. package/telegram-plugin/tests/reply-quote-wire.test.ts +47 -0
  35. package/telegram-plugin/tests/sticker-aliases.test.ts +43 -0
  36. package/telegram-plugin/tests/throttle-tier-probe-only.test.ts +216 -0
  37. package/telegram-plugin/tests/throttle-tier-route-429-wiring.test.ts +92 -0
  38. package/telegram-plugin/tests/throttle-tier-route-429.test.ts +71 -0
  39. package/telegram-plugin/throttle-tier.ts +59 -0
  40. package/vendor/hindsight-memory/CHANGELOG.md +31 -0
  41. package/vendor/hindsight-memory/hooks/hooks.json +2 -1
  42. package/vendor/hindsight-memory/scripts/session_start.py +35 -8
  43. package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
@@ -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()
@@ -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' }
@@ -220,6 +220,100 @@ export function stampsHandbackMarker(source: string | null | undefined): boolean
220
220
  */
221
221
  export const HANDBACK_RECENCY_WINDOW_MS = 60_000
222
222
 
223
+ // ───────────────────────────────────────────────────────────────────────────
224
+ // CLI task-notification dedup ledger (double-wake fix, v0.20.8 candidate)
225
+ // ───────────────────────────────────────────────────────────────────────────
226
+ //
227
+ // One background sub-agent completion used to produce TWO independent wakes of
228
+ // the parent session:
229
+ // 1. The claude CLI's OWN `<task-notification>` — the CLI proactively
230
+ // enqueues it into the parent session when a backgrounded task/agent
231
+ // completes (projected as a `task_notification` SessionEvent at the
232
+ // queue-operation enqueue line, session-tail.ts). The parent wakes,
233
+ // sees the notification + summary, and typically reports to the user.
234
+ // 2. The gateway-synthesized `subagent_handback` inbound (subagent-watcher
235
+ // `onFinish` → pendingInboundBuffer.push) — switchroom's deliberate
236
+ // beat-4 wake, added because older CLIs surfaced a background result
237
+ // only on the parent's NEXT user turn.
238
+ // Nothing linked them, so a single completion fanned out to two turns and two
239
+ // user-visible replies. The CLI-native wake cannot be suppressed (it is the
240
+ // CLI's internal queue); the ONE lever switchroom holds is the handback
241
+ // enqueue. This ledger records every terminal `<task-notification>` the
242
+ // session tail observes, keyed by its `<task-id>` — which is EXACTLY the
243
+ // sub-agent watcher's `agentId` (both are the `agent-<id>.jsonl` stem;
244
+ // verified against live transcripts: `<task-id>a204deeaedb27b580</task-id>`
245
+ // ↔ `subagents/agent-a204deeaedb27b580.jsonl`). `decideSubagentHandback`
246
+ // then skips the redundant handback ONLY on an exact-id hit inside a short
247
+ // TTL.
248
+ //
249
+ // FAIL-OPEN by construction — every uncertain path DELIVERS the handback:
250
+ // - no notification seen for this exact id → deliver (a dropped real wake
251
+ // means a silent worker and a user waiting forever; an occasional double
252
+ // is strictly better);
253
+ // - notification older than the TTL → deliver (a resumed worker's second
254
+ // completion must not be swallowed by its first completion's entry);
255
+ // - non-terminal notification status → never recorded, so → deliver;
256
+ // - gateway restart (ledger is in-memory) → boot-replayed handbacks
257
+ // deliver;
258
+ // - the CLI notification landing AFTER the handback enqueue (lost race) →
259
+ // deliver (the residual occasional double, accepted).
260
+ // The liveness consumer (background-shell-liveness.ts) is untouched: the same
261
+ // `task_notification` event still drives noteBackgroundShellDead —
262
+ // recording here is an independent, additive read of the event.
263
+
264
+ /**
265
+ * How long a recorded terminal `<task-notification>` suppresses the matching
266
+ * `subagent_handback`. The real gap between the CLI's notification enqueue
267
+ * and the watcher's `onFinish` (its ~1s jsonl rescan) is a few seconds; 30s
268
+ * covers scheduler jitter with a wide margin while staying far below any
269
+ * plausible resume-and-complete-again cycle for the same agent id, so a
270
+ * resumed worker's second completion falls outside the window → fail-open.
271
+ */
272
+ export const TASK_NOTIFICATION_DEDUP_TTL_MS = 30_000
273
+
274
+ /** `<task-notification>` statuses that mean the task genuinely ended and the
275
+ * CLI woke (or will wake) the parent with the completion. Mirrors the
276
+ * liveness consumer's terminal set (background-shell-liveness.ts). */
277
+ const NOTIF_TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed'])
278
+
279
+ /**
280
+ * In-memory seen-set of terminal CLI `<task-notification>` task ids.
281
+ * Deliberately process-local: absence after a restart fails open (deliver).
282
+ */
283
+ export class CliTaskNotificationLedger {
284
+ private readonly seen = new Map<string, number>()
285
+
286
+ /** Record a parsed `task_notification` session event. Non-terminal
287
+ * statuses and empty ids are ignored (they must never suppress). */
288
+ record(taskId: string, status: string, now: number): void {
289
+ if (taskId.length === 0 || !NOTIF_TERMINAL_STATUSES.has(status)) return
290
+ this.seen.set(taskId, now)
291
+ // Bounded: prune expired entries on write so the map tracks only the
292
+ // live window (a handful of ids), never the process lifetime.
293
+ for (const [id, ts] of this.seen) {
294
+ if (now - ts > TASK_NOTIFICATION_DEDUP_TTL_MS) this.seen.delete(id)
295
+ }
296
+ }
297
+
298
+ /** True iff a terminal notification for EXACTLY `taskId` was recorded
299
+ * within the TTL. Anything else → false → the handback delivers. */
300
+ seenRecently(taskId: string, now: number): boolean {
301
+ const ts = this.seen.get(taskId)
302
+ return ts != null && now - ts <= TASK_NOTIFICATION_DEDUP_TTL_MS
303
+ }
304
+ }
305
+
306
+ /**
307
+ * The gateway's process-wide ledger instance (one gateway process per agent,
308
+ * one main session — a module singleton keeps the gateway.ts wiring to two
309
+ * lines under the anti-inflation ratchet, #2996). Written at the gateway's
310
+ * `onSessionEvent` (OUTSIDE the currentTurn guard — the CLI enqueues the
311
+ * notification while the parent is typically idle with no live gateway turn);
312
+ * read at the `onFinish` handback decide site. Tests construct their own
313
+ * `CliTaskNotificationLedger` instances.
314
+ */
315
+ export const cliTaskNotifLedger = new CliTaskNotificationLedger()
316
+
223
317
  /** Sentinel thread key for the no-thread (DM / bare-chat) lane. */
224
318
  const MAIN_THREAD_KEY = '<main>'
225
319
 
@@ -35,6 +35,18 @@
35
35
  * (`LastFleetRoll` docstring in src/auth/broker/server.ts), which also
36
36
  * covers PINNED (non-fleet-active) account rolls — then nudges the resume
37
37
  * immediately through the same turn-safety guards.
38
+ *
39
+ * Two entrypoints (#failover-429-corroborate):
40
+ * - `fire` — the FULL path above, for an ACCOUNT-SCOPED 429 (wording that
41
+ * names the account's own usage limit). Records `throttled_until`, notices,
42
+ * nudges, escalates.
43
+ * - `fireProbeOnly` — for a GENERIC-TRANSIENT 429 (bare `rate_limit_error`
44
+ * wording that neither names the account nor is proxy-local). Runs ONLY the
45
+ * broker's rate-bounded escalation probe: a corroborated wall escalates
46
+ * exactly like `fire`, but a HEALTHY probe is account-inert — no notice, no
47
+ * nudge, no `throttled_until`, no second card. The gateway's calm
48
+ * rate-limited card stays the only user-visible output. The two share the
49
+ * escalation announce/resume via `announceEscalation`.
38
50
  */
39
51
 
40
52
  import {
@@ -56,7 +68,7 @@ export const THROTTLE_RETRY_NUDGE_JITTER_MAX_MS = 30_000
56
68
  /** The narrow broker surface the runner needs (structurally satisfied by
57
69
  * the gateway's AuthBrokerClient). */
58
70
  export interface ThrottleBrokerClient {
59
- markThrottled(until: number): Promise<{
71
+ markThrottled(until: number, probeOnly?: boolean): Promise<{
60
72
  account: string
61
73
  throttled_until: number
62
74
  escalated: boolean
@@ -92,10 +104,21 @@ export interface ThrottleTierRunnerDeps {
92
104
 
93
105
  export interface ThrottleTierRunner {
94
106
  /**
95
- * Run the throttle path for one terminal transient 429. Fire-and-forget
96
- * from the caller's perspective never throws.
107
+ * Run the FULL throttle path for one terminal account-scoped 429: record
108
+ * `throttled_until`, post one deduped notice, arm the retry nudge, and
109
+ * escalate to failover when the first-hit probe corroborates a wall.
110
+ * Fire-and-forget from the caller's perspective — never throws.
97
111
  */
98
112
  fire(triggerAgent: string, throttledUntilMs: number, resetParsed: boolean): Promise<void>
113
+ /**
114
+ * PROBE-ONLY path for a generic-transient 429 (#failover-429-corroborate).
115
+ * Runs ONLY the broker's rate-bounded escalation probe: a corroborated wall
116
+ * escalates (announce + resume nudge) exactly like `fire`; a HEALTHY probe is
117
+ * account-inert — NO notice, NO nudge, NO `throttled_until` soft-defer, NO
118
+ * second card. The calm rate-limited card the gateway already emitted stays
119
+ * the only user-visible output. Fire-and-forget; never throws.
120
+ */
121
+ fireProbeOnly(triggerAgent: string): Promise<void>
99
122
  /** Test/debug view of internal state. */
100
123
  inspect(): { noticeState: ThrottleNoticeState; nudgePending: boolean }
101
124
  }
@@ -180,6 +203,33 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
180
203
  }
181
204
  }
182
205
 
206
+ /**
207
+ * The escalated outcome, shared by `fire` and `fireProbeOnly`: the broker
208
+ * corroborated a genuine wall via a live probe and already ran mark-exhausted
209
+ * + roll (fleet active AND pinned accounts alike). The RAISING gateway
210
+ * announces — reactive-path doctrine — fleet-deduped so N gateways sharing
211
+ * the account produce one copy per chat, then nudges the resume.
212
+ */
213
+ async function announceEscalation(
214
+ client: ThrottleBrokerClient | null,
215
+ account: string | null,
216
+ rolledTo: string | null,
217
+ triggerAgent: string,
218
+ armedAtMs: number,
219
+ ): Promise<void> {
220
+ deps.log(
221
+ `[throttle-tier] escalated to wall account=${account ?? '?'} ` +
222
+ `rolledTo=${rolledTo ?? 'none (all blocked)'}`,
223
+ )
224
+ await broadcastDeduped(
225
+ client,
226
+ 'throttle-escalation',
227
+ account,
228
+ renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo }),
229
+ )
230
+ if (rolledTo) nudgeResume('throttle-escalation-resume', armedAtMs)
231
+ }
232
+
183
233
  async function fire(
184
234
  triggerAgent: string,
185
235
  throttledUntilMs: number,
@@ -209,21 +259,7 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
209
259
  }
210
260
 
211
261
  if (escalated) {
212
- // The broker corroborated a genuine wall via a live probe and already
213
- // ran mark-exhausted + roll (fleet active AND pinned accounts alike).
214
- // The RAISING gateway announces — reactive-path doctrine — fleet-
215
- // deduped so N gateways sharing the account produce one copy per chat.
216
- deps.log(
217
- `[throttle-tier] escalated to wall account=${account ?? '?'} ` +
218
- `rolledTo=${rolledTo ?? 'none (all blocked)'}`,
219
- )
220
- await broadcastDeduped(
221
- client,
222
- 'throttle-escalation',
223
- account,
224
- renderThrottleEscalationNotice({ account, agent: triggerAgent, rolledTo }),
225
- )
226
- if (rolledTo) nudgeResume('throttle-escalation-resume', armedAtMs)
262
+ await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
227
263
  return
228
264
  }
229
265
 
@@ -261,8 +297,47 @@ export function createThrottleTierRunner(deps: ThrottleTierRunnerDeps): Throttle
261
297
  }, delayMs)
262
298
  }
263
299
 
300
+ async function fireProbeOnly(triggerAgent: string): Promise<void> {
301
+ const armedAtMs = now()
302
+ let client: ThrottleBrokerClient | null = null
303
+ let account: string | null = null
304
+ let escalated = false
305
+ let rolledTo: string | null = null
306
+ try {
307
+ client = await deps.getBrokerClient()
308
+ if (client) {
309
+ // `until` is ignored by the broker in probeOnly mode (nothing is
310
+ // recorded) but the protocol requires a positive int — pass a nominal.
311
+ const r = await client.markThrottled(now() + 1, true)
312
+ account = r.account
313
+ escalated = r.escalated
314
+ rolledTo = r.rolledTo ?? null
315
+ } else {
316
+ deps.log(
317
+ `[throttle-tier] broker unreachable — probe-only skipped agent=${triggerAgent}`,
318
+ )
319
+ }
320
+ } catch (err) {
321
+ deps.log(
322
+ `[throttle-tier] probe-only markThrottled failed agent=${triggerAgent}: ${(err as Error)?.message ?? err}`,
323
+ )
324
+ }
325
+
326
+ if (escalated) {
327
+ await announceEscalation(client, account, rolledTo, triggerAgent, armedAtMs)
328
+ return
329
+ }
330
+
331
+ // HEALTHY / rate-bounded / broker-down: account-inert. NOTHING else — no
332
+ // notice, no retry nudge, no throttled_until soft-defer. The calm
333
+ // rate-limited card the gateway already emitted is the only user-visible
334
+ // output, exactly as before the escalation probe was wired.
335
+ deps.log(`[throttle-tier] generic-transient probe-only inert (no wall) agent=${triggerAgent}`)
336
+ }
337
+
264
338
  return {
265
339
  fire,
340
+ fireProbeOnly,
266
341
  inspect: () => ({ noticeState, nudgePending: pendingNudge != null }),
267
342
  }
268
343
  }
@@ -34,19 +34,38 @@
34
34
  // `__x__` double runs are excluded for free: the inner neighbour of each `*`
35
35
  // in `a**b` is another `*` (not alphanumeric), so neither half matches.
36
36
  //
37
+ // A SECOND, always-safe arm (added for issue #3464 alongside the line-start
38
+ // glued-`#` guard) escapes a `*` that is flanked by WHITESPACE OR A STRING
39
+ // BOUNDARY on BOTH immediate sides (`a * b`, ` * `, a bare `*`, a trailing
40
+ // `rm *`). Under GFM flanking rules such a `*` is neither left- nor right-
41
+ // flanking, so it can NEVER open OR close emphasis; escaping it therefore
42
+ // cannot break an intended `*italic*` / `**bold**` (whose delimiters are
43
+ // word-adjacent on their INNER side, so never whitespace-flanked on both
44
+ // sides). Because this `*` can never pair, the arm fires on a single
45
+ // occurrence — it needs no 2+ threshold. Live Telegram UAT for #3464 confirmed
46
+ // the non-spec Bot API parser can still surface a stray lone `*` in this
47
+ // position, so neutralising it deterministically is the durable fix.
48
+ //
49
+ // The ONE boundary-flanked position this arm must NOT touch is a line-leading
50
+ // `* ` — that is an unordered-list BULLET (`* item`), not an inert operator.
51
+ // Escaping it would break `*`-bullets on every outbound message AND (because
52
+ // this arm runs before `guardAccidentalHeading`) disarm the glued-`#` fix for
53
+ // `* #4382`. See `isLineLeadingBullet`. A lone `*` on its own line has no
54
+ // trailing space, is not a bullet, and is still escaped.
55
+ //
37
56
  // What we DELIBERATELY LEAVE ALONE (conservative false-negatives, per the
38
57
  // "when in doubt, leave it" doctrine inherited from the dollar guard):
39
- // - Space-flanked operators (`3 * 4`): a whitespace-flanked `*`/`_` is
40
- // neither left- nor right-flanking under GFM, so it can never open or
41
- // close emphasis there is no bug to fix, and escaping it would be pure
42
- // churn.
43
- // - Boundary-flanked delimiters (`rm *`, `*.ts`, leading-`_` `_private`):
44
- // these are INDISTINGUISHABLE from an intended `*glob*` / `_italic_`
45
- // opener (`*.ts is a glob*` is a legitimate italic). Escaping them would
46
- // risk breaking intended emphasisthe one thing this guard must never
47
- // do so a glob/leading-underscore that pairs into an accidental span is
48
- // accepted as a rare false-negative rather than risked as a false-positive.
49
- // (A future arm could target these behind live Telegram UAT; see below.)
58
+ // - Boundary-flanked delimiters with a NON-whitespace neighbour on the other
59
+ // side (`*.ts`, leading-`_` `_private`): these are INDISTINGUISHABLE from
60
+ // an intended `*glob*` / `_italic_` opener (`*.ts is a glob*` is a
61
+ // legitimate italic). Escaping them would risk breaking intended emphasis —
62
+ // the one thing this guard must never do — so a glob/leading-underscore
63
+ // that pairs into an accidental span is accepted as a rare false-negative
64
+ // rather than risked as a false-positive. (The whitespace-on-BOTH-sides
65
+ // form `rm *` is NOT in this class a `*` with whitespace/boundary on both
66
+ // sides is provably inert, so the new arm above escapes it safely.)
67
+ // `_` in this position is likewise left alone; the boundary-flanked arm is
68
+ // `*`-only.
50
69
  //
51
70
  // Idempotent: the escape is expressed as an intra-word match, so an
52
71
  // already-escaped `\_`/`\*` has a backslash (not an alphanumeric) immediately
@@ -91,6 +110,47 @@ const INTRA_WORD_UNDERSCORE = /(?<=[A-Za-z0-9])_(?=[A-Za-z0-9])/g;
91
110
  * neighbour is `*` (not alnum). Idempotent for the same reason as above. */
92
111
  const INTRA_WORD_ASTERISK = /(?<=[A-Za-z0-9])\*(?=[A-Za-z0-9])/g;
93
112
 
113
+ /** A `*` flanked by WHITESPACE OR A STRING BOUNDARY on BOTH immediate sides
114
+ * (`a * b`, ` * `, a bare `*`, `rm *`). Neither left- nor right-flanking under
115
+ * GFM, so it can never open or close emphasis — escaping it is always safe and
116
+ * can never touch an intended `*italic*` / `**bold**` (word-adjacent on the
117
+ * inner side). Idempotent: after escaping, the `*` is preceded by `\` (neither
118
+ * whitespace nor `^`), so the lookbehind no longer matches. `\n` is whitespace,
119
+ * so a `*` alone on its own line is covered without a multiline flag.
120
+ *
121
+ * CRITICAL EXCLUSION — a line-leading `* ` is an unordered-LIST BULLET, not an
122
+ * inert operator, and MUST NOT be escaped (`isLineLeadingBullet` below). Two
123
+ * reasons: (a) `*`-bullets are extremely common in replies and escaping the
124
+ * marker would break the list on EVERY outbound message (an asymmetric
125
+ * regression — `-`/`+` bullets are untouched); (b) this guard runs BEFORE
126
+ * `guardAccidentalHeading` in the `guardAccidentalFormatting` pipeline
127
+ * (rich-send.ts), and the heading guard's `ACCIDENTAL_HEADING_AFTER_MARKER`
128
+ * needs the LITERAL `*` marker to fire — escaping the marker here would silently
129
+ * disarm the glued-`#` fix for the `* #4382` case (#3464). A lone `*` on its own
130
+ * line (`\n*\n`) is NOT a bullet — no trailing space — so it is still escaped. */
131
+ const BOUNDARY_FLANKED_ASTERISK = /(?<=^|\s)\*(?=\s|$)/g;
132
+
133
+ /** True iff the `*` at `starIndex` is a line-leading unordered-list bullet: a
134
+ * line start (string start or after `\n`) + up to 3 spaces/tabs of indent, then
135
+ * the `*`, then a space/tab. This is the ONE boundary-flanked position we leave
136
+ * alone (see BOUNDARY_FLANKED_ASTERISK). Note: a segment that begins mid-line
137
+ * (right after an inline code span) has its start treated as a line start here,
138
+ * which errs toward PRESERVING an ambiguous leading `*` — the safe direction,
139
+ * since wrongly escaping a bullet is the failure this exclusion exists to stop. */
140
+ function isLineLeadingBullet(text: string, starIndex: number): boolean {
141
+ // A bullet requires a space/tab immediately after the marker.
142
+ const next = text[starIndex + 1];
143
+ if (next !== " " && next !== "\t") return false;
144
+ // Walk left over up to 3 indent spaces/tabs; the run must reach a line start.
145
+ let i = starIndex - 1;
146
+ let indent = 0;
147
+ while (i >= 0 && (text[i] === " " || text[i] === "\t")) {
148
+ if (++indent > 3) return false;
149
+ i--;
150
+ }
151
+ return i < 0 || text[i] === "\n";
152
+ }
153
+
94
154
  /** Every unescaped `_` / `*` in prose (the pair-threshold input). An emphasis
95
155
  * span needs a MATCHING pair of the same delimiter, so a delimiter can only
96
156
  * mis-render when 2+ of it exist. The `(?<!\\)` keeps the count idempotent. */
@@ -129,22 +189,37 @@ export function guardAccidentalEmphasis(text: string): string {
129
189
  // 2+ of that delimiter exist (so a pair — hence a mis-render — is possible).
130
190
  let hasIntraUnderscore = false;
131
191
  let hasIntraAsterisk = false;
192
+ let hasBoundaryAsterisk = false;
132
193
  let underscoreCount = 0;
133
194
  let asteriskCount = 0;
134
195
  for (const seg of segments) {
135
196
  if (seg.code) continue;
136
197
  if (INTRA_WORD_UNDERSCORE.test(seg.text)) hasIntraUnderscore = true;
137
198
  if (INTRA_WORD_ASTERISK.test(seg.text)) hasIntraAsterisk = true;
199
+ // Arm only on a boundary-flanked `*` that is NOT a line-leading bullet, so a
200
+ // segment whose only such `*` is a bullet keeps the guard a strict no-op.
201
+ if (!hasBoundaryAsterisk) {
202
+ for (const m of seg.text.matchAll(BOUNDARY_FLANKED_ASTERISK)) {
203
+ if (!isLineLeadingBullet(seg.text, m.index ?? 0)) {
204
+ hasBoundaryAsterisk = true;
205
+ break;
206
+ }
207
+ }
208
+ }
138
209
  underscoreCount += seg.text.match(ANY_UNDERSCORE)?.length ?? 0;
139
210
  asteriskCount += seg.text.match(ANY_ASTERISK)?.length ?? 0;
140
211
  }
141
212
  // Reset lastIndex — the /g regexes above are stateful across .test() calls.
142
213
  INTRA_WORD_UNDERSCORE.lastIndex = 0;
143
214
  INTRA_WORD_ASTERISK.lastIndex = 0;
215
+ BOUNDARY_FLANKED_ASTERISK.lastIndex = 0;
144
216
 
145
217
  const armUnderscore = hasIntraUnderscore && underscoreCount >= 2;
146
218
  const armAsterisk = hasIntraAsterisk && asteriskCount >= 2;
147
- if (!armUnderscore && !armAsterisk) return text;
219
+ // The boundary-flanked `*` can NEVER pair (neither flanking), so no 2+
220
+ // threshold: a single occurrence is escaped on its own.
221
+ const armBoundaryAsterisk = hasBoundaryAsterisk;
222
+ if (!armUnderscore && !armAsterisk && !armBoundaryAsterisk) return text;
148
223
 
149
224
  return segments
150
225
  .map((seg) => {
@@ -152,6 +227,11 @@ export function guardAccidentalEmphasis(text: string): string {
152
227
  let out = seg.text;
153
228
  if (armUnderscore) out = out.replace(INTRA_WORD_UNDERSCORE, "\\_");
154
229
  if (armAsterisk) out = out.replace(INTRA_WORD_ASTERISK, "\\*");
230
+ if (armBoundaryAsterisk) {
231
+ out = out.replace(BOUNDARY_FLANKED_ASTERISK, (m, offset: number, str: string) =>
232
+ isLineLeadingBullet(str, offset) ? m : "\\*",
233
+ );
234
+ }
155
235
  return out;
156
236
  })
157
237
  .join("");
@@ -187,13 +187,38 @@ export function guardAccidentalBlockConstructs(text: string): string {
187
187
  * `#{1,6}` no longer sits at the (post-indent) line start. */
188
188
  const ACCIDENTAL_HEADING = /^([ \t]{0,3})(#{1,6})(?=[^\s#])/;
189
189
 
190
+ /** Same accidental-heading run, but sitting AFTER one or more line-start LIST or
191
+ * BLOCKQUOTE markers on the SAME line (`- #4382`, `* #4382`, `+ #4382`,
192
+ * `1. #4382`, `> #4382`, and nested combinations like `- > #4382`) — issue
193
+ * #3464. `guardAccidentalHeading`'s original `^`-anchored pattern only sees a
194
+ * `#` at the true (post-indent) line start, so a `#` glued after a marker slips
195
+ * through and Telegram's non-spec Bot API parser promotes it to a heading just
196
+ * as it does at a bare line start (confirmed by live UAT for #3464). The prefix
197
+ * is `([ \t]{0,3}<marker>+)`: up to 3 leading spaces, then one-or-more markers,
198
+ * each an unordered bullet (`-`/`*`/`+`) or short ordered marker (`1.`/`1)`,
199
+ * 1–3 digits) followed by ≥1 space/tab, or a blockquote `>` with optional
200
+ * spaces. Group 1 captures the WHOLE prefix so the replacement re-emits it
201
+ * verbatim and escapes ONLY the first `#` of group 2. The same `(?=[^\s#])`
202
+ * lookahead keeps a real nested heading (`- # Heading`, space AFTER the `#`)
203
+ * and a bare `#`/`##` untouched. Idempotent: after escaping, the `#` sits
204
+ * behind a `\` (`- \#4382`), so the `(#{1,6})` no longer follows the prefix. */
205
+ const ACCIDENTAL_HEADING_AFTER_MARKER =
206
+ /^([ \t]{0,3}(?:(?:[-*+]|\d{1,3}[.)])[ \t]+|>[ \t]*)+)(#{1,6})(?=[^\s#])/;
207
+
190
208
  /**
191
209
  * Escape the accidental heading trigger at the start of ONE line (the string
192
210
  * must NOT contain a newline; the caller guarantees a true line start). A no-op
193
- * unless the line begins with a `#{1,6}` run glued to a non-space, non-`#` char.
211
+ * unless the line begins with a `#{1,6}` run glued to a non-space, non-`#` char
212
+ * — either at the true line start (`#4382`) or immediately after one or more
213
+ * line-start list/blockquote markers (`- #4382`, `> #4382`, `1. #4382`, #3464).
214
+ * The two patterns are mutually exclusive on any given line (the bare one needs
215
+ * a `#` right after the indent; the marker one needs a marker first), so running
216
+ * both replaces can never double-escape.
194
217
  */
195
218
  function escapeAccidentalHeadingLine(line: string): string {
196
- return line.replace(ACCIDENTAL_HEADING, "$1\\$2");
219
+ return line
220
+ .replace(ACCIDENTAL_HEADING, "$1\\$2")
221
+ .replace(ACCIDENTAL_HEADING_AFTER_MARKER, "$1\\$2");
197
222
  }
198
223
 
199
224
  /**
@@ -18,6 +18,8 @@
18
18
  * surface uses: trust-by-explicit-listing.
19
19
  */
20
20
 
21
+ import { parseSourceMessageId } from './gateway/source-message-id.js'
22
+
21
23
  export interface StickerAliasMap {
22
24
  /** alias name → Telegram file_id. Both validated on read. */
23
25
  [alias: string]: string
@@ -132,13 +134,12 @@ export function resolveStickerSendArgs(
132
134
  throw new Error('send_sticker: message_thread_id must be a positive integer string')
133
135
  }
134
136
  }
135
- let replyTo: number | undefined
136
- if (raw.reply_to != null) {
137
- replyTo = Number(raw.reply_to)
138
- if (!Number.isFinite(replyTo) || replyTo <= 0) {
139
- throw new Error('send_sticker: reply_to must be a positive integer string')
140
- }
141
- }
137
+ // #4368 route the agent-supplied reply anchor through the canonical
138
+ // guard. A fabricated (synthetic / out-of-int32) message id — e.g. an agent
139
+ // echoing a boot-resume/handback inbound's `Date.now()`-scale id — yields
140
+ // null, so the sticker sends UNANCHORED rather than 400ing the whole send on
141
+ // `reply_parameters.message_id` (which Telegram hard-rejects out of range).
142
+ const replyTo = parseSourceMessageId(raw.reply_to) ?? undefined
142
143
 
143
144
  return {
144
145
  chatId: raw.chat_id,
@@ -230,13 +231,10 @@ export function resolveGifSendArgs(raw: GifSendArgs): ValidatedGifSendArgs {
230
231
  throw new Error('send_gif: message_thread_id must be a positive integer string')
231
232
  }
232
233
  }
233
- let replyTo: number | undefined
234
- if (raw.reply_to != null) {
235
- replyTo = Number(raw.reply_to)
236
- if (!Number.isFinite(replyTo) || replyTo <= 0) {
237
- throw new Error('send_gif: reply_to must be a positive integer string')
238
- }
239
- }
234
+ // #4368 route the agent-supplied reply anchor through the canonical guard
235
+ // (see resolveStickerSendArgs). A fabricated / out-of-int32 id sends the GIF
236
+ // UNANCHORED instead of 400ing on `reply_parameters.message_id`.
237
+ const replyTo = parseSourceMessageId(raw.reply_to) ?? undefined
240
238
 
241
239
  return {
242
240
  chatId: raw.chat_id,
@@ -106,6 +106,21 @@ describe('validateAskUserArgs — optional fields', () => {
106
106
  const r = validateAskUserArgs({ chat_id: '1', question: 'q', options: ['a', 'b'], reply_to: '99' })
107
107
  expect(r.replyTo).toBe(99)
108
108
  })
109
+
110
+ // #4368 — a fabricated reply anchor (a synthetic boot-resume/handback/cron
111
+ // inbound id at `Date.now()` scale) is out of the signed-int32 range Telegram
112
+ // accepts for `reply_parameters.message_id`. It must be DROPPED so the
113
+ // ask_user prompt still sends (unanchored) rather than 400ing the whole send.
114
+ // Before the fix `Number(args.reply_to)` let a 13-digit id through unchanged.
115
+ it('drops an out-of-int32 reply_to so the prompt sends unanchored (#4368)', () => {
116
+ const r = validateAskUserArgs({
117
+ chat_id: '1',
118
+ question: 'q',
119
+ options: ['a', 'b'],
120
+ reply_to: String(1_785_000_000_000),
121
+ })
122
+ expect(r.replyTo).toBeUndefined()
123
+ })
109
124
  })
110
125
 
111
126
  describe('validateAskUserArgs — timeout clamping', () => {
@@ -146,6 +146,27 @@ describe('native payloads — correct Bot API 9.1 shape', () => {
146
146
  expect(JSON.stringify(payload)).not.toContain('is_completed')
147
147
  })
148
148
 
149
+ // #4368 — a fabricated reply anchor (a synthetic boot-resume/handback/cron
150
+ // inbound id at `Date.now()` scale) is out of the signed-int32 range Telegram
151
+ // accepts for `reply_parameters.message_id`. The builder must DROP it so the
152
+ // native checklist sends UNANCHORED rather than 400ing the whole send. Before
153
+ // the fix the builder emitted `reply_parameters.message_id` verbatim.
154
+ it('drops an out-of-int32 replyToMessageId — no reply_parameters (#4368)', () => {
155
+ const payload = buildNativeChecklistPayload({
156
+ businessConnectionId: 'bc1',
157
+ chatId: 42,
158
+ title: 'T',
159
+ tasks: buildChecklistTasks([{ text: 'a' }]),
160
+ replyToMessageId: 1_785_000_000_000,
161
+ })
162
+ expect(payload.reply_parameters).toBeUndefined()
163
+ expect(payload).toEqual({
164
+ business_connection_id: 'bc1',
165
+ chat_id: 42,
166
+ checklist: { title: 'T', tasks: [{ id: 1, text: 'a' }] },
167
+ })
168
+ })
169
+
149
170
  it('editMessageChecklist sends the FULL checklist (native edits replace it)', () => {
150
171
  const payload = buildNativeEditChecklistPayload({
151
172
  businessConnectionId: 'bc1',