switchroom 0.14.19 → 0.14.20

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.
@@ -24260,7 +24260,7 @@ var init_bridge = __esm(async () => {
24260
24260
  instructions: [
24261
24261
  "The sender reads Telegram, not this session. Anything you want them to see must go through the reply tool \u2014 your transcript output never reaches their chat.",
24262
24262
  "",
24263
- 'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. Reply with the reply tool \u2014 pass chat_id back. The reply and stream_reply tools quote-reply to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
24263
+ 'Messages from Telegram arrive as <channel source="telegram" chat_id="..." message_id="..." user="..." ts="...">. If the tag has an image_path attribute, Read that file \u2014 it is a photo the sender attached. If the tag has attachment_file_id, call download_attachment with that file_id to fetch the file, then Read the returned path. A single message may carry SEVERAL attachments (a forwarded album or a text+multi-image burst): when attachment_count is set (>1), also handle the numbered siblings \u2014 image_path_2, image_path_3, \u2026 (Read each) and attachment_file_id_2, attachment_file_id_3, \u2026 (download_attachment each). Process every one, not just the first. Reply with the reply tool \u2014 pass chat_id back. The reply and stream_reply tools quote-reply to the latest inbound user message by default, so you do NOT need to pass reply_to for normal responses. Pass reply_to (a message_id) only when quoting a specific earlier message, or pass quote:false to send a bare (non-quoted) message.',
24264
24264
  "",
24265
24265
  `reply accepts file paths (files: ["/abs/path.png"]) for attachments. Use react to add emoji reactions, edit_message for interim progress updates, and delete_message when you need to truly remove a message (prefer edit_message if you just want to change text \u2014 delete is for retraction). Edits don't trigger push notifications \u2014 when a long task completes, send a new reply so the user's device pings. Use send_typing to show a typing indicator during long operations. Use pin_message to pin important outputs. Use forward_message to quote/resurface earlier messages.`,
24266
24266
  "",
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pure helpers for A2 multi-attachment coalescing — kept out of `gateway.ts`
3
+ * so the cap/ordering and numbered-meta logic can be unit-tested without the
4
+ * gateway's `loadAccess()` / IPC machinery.
5
+ *
6
+ * Inbound model: each Telegram message carries at most one attachment, so the
7
+ * coalescer accumulates one attachment per buffered entry. On flush the
8
+ * gateway folds up to `coalesce.max_attachments` of them into a single turn —
9
+ * the first is the primary (unsuffixed `image_path` / `attachment_*` meta),
10
+ * the rest are numbered siblings (`image_path_2`, `attachment_file_id_2`, …).
11
+ */
12
+
13
+ export interface CoalesceAttachmentMeta {
14
+ kind: string
15
+ file_id: string
16
+ size?: number
17
+ mime?: string
18
+ name?: string
19
+ }
20
+
21
+ /** A resolved extra attachment: photos are pre-downloaded to `imagePath`;
22
+ * documents/voice carry only `attachment` metadata (agent fetches the file
23
+ * via `download_attachment`). */
24
+ export interface ResolvedExtraAttachment {
25
+ imagePath?: string
26
+ attachment?: CoalesceAttachmentMeta
27
+ }
28
+
29
+ /**
30
+ * Split the attachment-bearing entries of a coalesce window into the primary
31
+ * entry plus the capped list of extras. Preserves arrival order so a
32
+ * `[photo][text][photo]` burst keeps both photos in the order sent. Entries
33
+ * past `maxAttachments` are dropped here (the gateway bypasses them to their
34
+ * own turn upstream, so nothing is actually lost).
35
+ *
36
+ * `maxAttachments` is floored at 1 — a cap of 0 or negative would strip the
37
+ * primary, silently dropping the only attachment.
38
+ */
39
+ export function splitCoalescedAttachments<T>(
40
+ entries: T[],
41
+ hasAttachment: (e: T) => boolean,
42
+ maxAttachments: number,
43
+ ): { primary: T | undefined; extras: T[] } {
44
+ const withAttachment = entries.filter(hasAttachment)
45
+ const capped = withAttachment.slice(0, Math.max(1, maxAttachments))
46
+ const [primary, ...extras] = capped
47
+ return { primary, extras: extras }
48
+ }
49
+
50
+ /**
51
+ * Build the numbered meta fields for the resolved extra attachments. The
52
+ * primary occupies the unsuffixed keys, so extras start at `_2`.
53
+ */
54
+ export function buildExtraAttachmentMeta(
55
+ resolved: ResolvedExtraAttachment[],
56
+ ): Record<string, string> {
57
+ const out: Record<string, string> = {}
58
+ resolved.forEach((ex, i) => {
59
+ const n = i + 2
60
+ if (ex.imagePath) out[`image_path_${n}`] = ex.imagePath
61
+ if (ex.attachment) {
62
+ out[`attachment_kind_${n}`] = ex.attachment.kind
63
+ out[`attachment_file_id_${n}`] = ex.attachment.file_id
64
+ if (ex.attachment.size != null) out[`attachment_size_${n}`] = String(ex.attachment.size)
65
+ if (ex.attachment.mime) out[`attachment_mime_${n}`] = ex.attachment.mime
66
+ if (ex.attachment.name) out[`attachment_name_${n}`] = ex.attachment.name
67
+ }
68
+ })
69
+ return out
70
+ }
@@ -35,6 +35,11 @@ import {
35
35
  type AskUserOutcome,
36
36
  } from '../ask-user.js'
37
37
  import { parseInterruptMarker } from '../interrupt-marker.js'
38
+ import {
39
+ ToolFlightTracker,
40
+ decideInterruptTiming,
41
+ resolveInterruptMaxWaitMs,
42
+ } from './interrupt-defer.js'
38
43
  import {
39
44
  resolveStickerSendArgs,
40
45
  resolveGifSendArgs,
@@ -51,6 +56,7 @@ import {
51
56
  } from '../telegraph.js'
52
57
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
53
58
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
59
+ import { splitCoalescedAttachments, buildExtraAttachmentMeta } from './coalesce-attachments.js'
54
60
  import { StatusReactionController } from '../status-reactions.js'
55
61
  import { DeferredDoneReactions } from '../reaction-defer.js'
56
62
  import { createWorkerActivityFeed } from '../worker-activity-feed.js'
@@ -770,6 +776,19 @@ type Access = {
770
776
  parseMode?: 'html' | 'markdownv2' | 'text'
771
777
  disableLinkPreview?: boolean
772
778
  coalescingGapMs?: number
779
+ /** A2: max media attachments folded into one coalesced turn. Default 1
780
+ * (single-attachment behaviour). Projected from
781
+ * channels.telegram.coalesce.max_attachments by scaffold. */
782
+ coalesceMaxAttachments?: number
783
+ /** Problem B: when true, a `!` interrupt that lands mid-tool-call is
784
+ * deferred until the in-flight tool finishes (bounded by
785
+ * interruptMaxWaitMs) before SIGINT + resume. Default false (fire
786
+ * synchronously). Projected from channels.telegram.interrupt.safe_boundary. */
787
+ interruptSafeBoundary?: boolean
788
+ /** Upper bound (ms) to wait for a safe boundary before firing a deferred
789
+ * interrupt anyway. Default 8000. Projected from
790
+ * channels.telegram.interrupt.max_wait_ms. */
791
+ interruptMaxWaitMs?: number
773
792
  statusReactions?: boolean
774
793
  historyEnabled?: boolean
775
794
  historyRetentionDays?: number
@@ -868,6 +887,9 @@ function readAccessFile(): Access {
868
887
  parseMode: parsed.parseMode,
869
888
  disableLinkPreview: parsed.disableLinkPreview,
870
889
  coalescingGapMs: parsed.coalescingGapMs,
890
+ coalesceMaxAttachments: parsed.coalesceMaxAttachments,
891
+ interruptSafeBoundary: parsed.interruptSafeBoundary,
892
+ interruptMaxWaitMs: parsed.interruptMaxWaitMs,
871
893
  statusReactions: parsed.statusReactions,
872
894
  historyEnabled: parsed.historyEnabled,
873
895
  historyRetentionDays: parsed.historyRetentionDays,
@@ -1380,6 +1402,78 @@ type CurrentTurn = {
1380
1402
 
1381
1403
  let currentTurn: CurrentTurn | null = null
1382
1404
 
1405
+ // Problem B — deferred safe-boundary interrupt.
1406
+ //
1407
+ // `toolFlightTracker` mirrors the session-event stream to know whether a
1408
+ // top-level tool call is open right now (an unsafe point to SIGINT). When the
1409
+ // `interrupt.safe_boundary` flag is on and a `!` lands mid-tool-call, we don't
1410
+ // fire the SIGINT — we stash the fully-built replacement inbound here and fire
1411
+ // it (SIGINT + deliver) at the next clean boundary (tool_result drains the
1412
+ // last open tool, or turn_end), or when the max-wait timer expires. Rapid
1413
+ // repeated `!` while one is pending coalesce: the latest body replaces the
1414
+ // stashed inbound, the original deadline is preserved (bounded wait).
1415
+ const toolFlightTracker = new ToolFlightTracker()
1416
+
1417
+ interface PendingDeferredInterrupt {
1418
+ agentName: string
1419
+ inboundMsg: InboundMessage
1420
+ chatId: string
1421
+ msgId: number | null
1422
+ threadId: number | undefined
1423
+ registeredAt: number
1424
+ deadlineTimer: ReturnType<typeof setTimeout>
1425
+ }
1426
+ let pendingDeferredInterrupt: PendingDeferredInterrupt | null = null
1427
+
1428
+ /**
1429
+ * Fire a stashed deferred interrupt: SIGINT the (now safely-bounded) turn via
1430
+ * tmux, then deliver the replacement body as a fresh inbound — the same two
1431
+ * primitives the synchronous `!` path uses, just gated on a clean boundary.
1432
+ * Idempotent: nulls the slot and clears the timer before doing any work so a
1433
+ * boundary event and the timeout can't double-fire.
1434
+ */
1435
+ async function fireDeferredInterrupt(reason: 'boundary' | 'timeout'): Promise<void> {
1436
+ const pending = pendingDeferredInterrupt
1437
+ if (pending == null) return
1438
+ pendingDeferredInterrupt = null
1439
+ clearTimeout(pending.deadlineTimer)
1440
+
1441
+ const waitedMs = Date.now() - pending.registeredAt
1442
+ process.stderr.write(
1443
+ `telegram gateway: deferred-interrupt firing reason=${reason} agent=${pending.agentName} ` +
1444
+ `chat=${pending.chatId} waited_ms=${waitedMs} in_flight=${toolFlightTracker.inFlightCount()}\n`,
1445
+ )
1446
+
1447
+ try {
1448
+ const { sendAgentInterrupt } = await import('../../src/agents/tmux.js')
1449
+ const r = sendAgentInterrupt({ agentName: pending.agentName })
1450
+ if ('ok' in r) {
1451
+ process.stderr.write(
1452
+ `telegram gateway: deferred-interrupt SIGINT delivered via tmux send-keys agent=${pending.agentName}\n`,
1453
+ )
1454
+ } else {
1455
+ process.stderr.write(
1456
+ `telegram gateway: deferred-interrupt SIGINT via tmux failed agent=${pending.agentName}: ${r.error}\n`,
1457
+ )
1458
+ }
1459
+ } catch (err) {
1460
+ process.stderr.write(`telegram gateway: deferred-interrupt SIGINT failed: ${(err as Error).message}\n`)
1461
+ }
1462
+
1463
+ // Deliver the replacement body as a fresh turn to the freshly-killed
1464
+ // bridge — same sendToAgent + buffer-on-miss primitive the synchronous
1465
+ // interrupt carve-out uses at the handleInbound delivery site.
1466
+ const delivered = ipcServer.sendToAgent(pending.agentName, pending.inboundMsg)
1467
+ if (delivered) {
1468
+ markClaudeBusyForInbound(pending.inboundMsg)
1469
+ } else {
1470
+ pendingInboundBuffer.push(pending.agentName, pending.inboundMsg)
1471
+ process.stderr.write(
1472
+ `telegram gateway: deferred-interrupt body buffered (bridge miss) agent=${pending.agentName} chat=${pending.chatId}\n`,
1473
+ )
1474
+ }
1475
+ }
1476
+
1383
1477
  // #549 fix — preamble suppression for the answer-stream path.
1384
1478
  //
1385
1479
  // Background: assistant text emitted before a tool_use is "preamble"
@@ -3014,28 +3108,43 @@ type AttachmentMeta = {
3014
3108
  name?: string
3015
3109
  }
3016
3110
 
3111
+ // One attachment slot carried by a coalesced message — primary or extra.
3112
+ type CoalesceAttachment = {
3113
+ downloadImage?: () => Promise<string | undefined>
3114
+ attachment?: AttachmentMeta
3115
+ }
3116
+
3017
3117
  // CoalescePayload is what the InboundCoalescer carries per buffered message.
3018
3118
  // `ctx` must be the *latest* message's context (latest message_id, etc.) so
3019
3119
  // the merge function picks the last entry's ctx.
3020
3120
  //
3021
- // A single attachment-bearing message may ride along in a coalesce window
3022
- // (so a [text][photo] forward becomes one turn). The handleInboundCoalesced
3023
- // guards ensure AT MOST ONE attachment per window albums (media_group_id)
3024
- // and a second attachment both bypass to their own turn — so the single
3025
- // `downloadImage`/`attachment` slot is never silently overwritten. Folding a
3026
- // whole album into one multi-attachment turn is the A2 follow-on.
3121
+ // Each inbound Telegram message carries at most one attachment, so an enqueued
3122
+ // payload sets at most `downloadImage`/`attachment`. The merge collects every
3123
+ // attachment-bearing entry in the window (up to coalesce.max_attachments): the
3124
+ // first becomes the primary `downloadImage`/`attachment`, the rest ride along
3125
+ // in `extraAttachments` (A2). When the cap is 1 (default), the
3126
+ // handleInboundCoalesced guards still bypass a second attachment / album part
3127
+ // to its own turn, so the single-attachment behaviour is byte-for-byte
3128
+ // preserved.
3027
3129
  type CoalescePayload = {
3028
3130
  text: string
3029
3131
  ctx: Context
3030
3132
  downloadImage?: () => Promise<string | undefined>
3031
3133
  attachment?: AttachmentMeta
3134
+ // Set only by `merge`: the 2nd..Nth attachments folded into this turn.
3135
+ extraAttachments?: CoalesceAttachment[]
3032
3136
  }
3033
3137
 
3034
- // Coalesce keys whose open window already holds an attachment-bearing entry.
3035
- // A second attachment for the same key bypasses coalescing (see
3036
- // handleInboundCoalesced) so the single-attachment merge can't drop a photo.
3037
- // Cleared on flush (below) and on the synchronous bypass path.
3038
- const bufferedAttachmentKeys = new Set<string>()
3138
+ // Count of attachment-bearing entries currently buffered per coalesce key.
3139
+ // A new attachment for a key whose count has reached the per-agent cap
3140
+ // (coalesce.max_attachments, default 1) bypasses coalescing (see
3141
+ // handleInboundCoalesced) so no media is dropped past the cap. Cleared on
3142
+ // flush (below) and on the synchronous bypass path.
3143
+ const bufferedAttachmentKeys = new Map<string, number>()
3144
+
3145
+ function coalesceMaxAttachments(): number {
3146
+ return Math.max(1, loadAccess().coalesceMaxAttachments ?? 1)
3147
+ }
3039
3148
 
3040
3149
  const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
3041
3150
  // Read per-call from the access file so an operator-tuned
@@ -3047,21 +3156,36 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
3047
3156
  gapMs: () => loadAccess().coalescingGapMs ?? 500,
3048
3157
  merge: (entries) => {
3049
3158
  const last = entries[entries.length - 1]
3050
- // At most one entry carries an attachment (guarded upstream), so pick
3051
- // whichever entry has it rather than blindly taking `last` — a
3052
- // [photo][text] burst keeps its image even though the last entry is
3053
- // text-only.
3054
- const withAttachment = entries.find((e) => e.downloadImage != null || e.attachment != null)
3159
+ // Collect every attachment-bearing entry in arrival order. The first is
3160
+ // the primary (unsuffixed image_path/attachment_* meta); the remainder,
3161
+ // capped at max_attachments, become numbered extras. A [photo][text]
3162
+ // burst keeps its image even though the last entry is text-only.
3163
+ const { primary, extras } = splitCoalescedAttachments(
3164
+ entries,
3165
+ (e) => e.downloadImage != null || e.attachment != null,
3166
+ coalesceMaxAttachments(),
3167
+ )
3055
3168
  return {
3056
- text: entries.map((e) => e.text).join('\n'),
3169
+ // Drop empty texts (e.g. caption-less album parts) so the join doesn't
3170
+ // emit blank lines between attachments.
3171
+ text: entries.map((e) => e.text).filter((t) => t.length > 0).join('\n'),
3057
3172
  ctx: last.ctx,
3058
- downloadImage: withAttachment?.downloadImage,
3059
- attachment: withAttachment?.attachment,
3173
+ downloadImage: primary?.downloadImage,
3174
+ attachment: primary?.attachment,
3175
+ extraAttachments: extras.length > 0
3176
+ ? extras.map((e) => ({ downloadImage: e.downloadImage, attachment: e.attachment }))
3177
+ : undefined,
3060
3178
  }
3061
3179
  },
3062
3180
  onFlush: (key, merged) => {
3063
3181
  bufferedAttachmentKeys.delete(key)
3064
- void handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment)
3182
+ void handleInbound(
3183
+ merged.ctx,
3184
+ merged.text,
3185
+ merged.downloadImage,
3186
+ merged.attachment,
3187
+ merged.extraAttachments,
3188
+ )
3065
3189
  },
3066
3190
  })
3067
3191
 
@@ -4128,6 +4252,14 @@ const ipcServer: IpcServer = createIpcServer({
4128
4252
  const threadHint = msg.threadId != null ? String(msg.threadId) : undefined
4129
4253
  progressDriver?.ingest(ev, chatHint, threadHint)
4130
4254
  handleSessionEvent(ev)
4255
+ // Problem B: keep the deferred-interrupt boundary tracker in lockstep with
4256
+ // the session stream (tool_use opens, tool_result/turn_end close). If a `!`
4257
+ // interrupt is parked waiting for a clean boundary and this event drains
4258
+ // the last in-flight tool, fire it now rather than waiting out the timer.
4259
+ toolFlightTracker.onEvent(ev)
4260
+ if (pendingDeferredInterrupt != null && !toolFlightTracker.isMidToolCall()) {
4261
+ void fireDeferredInterrupt('boundary')
4262
+ }
4131
4263
  // #1122 silence-poke: surface activity signals from the session
4132
4264
  // stream so the 300s framework-fallback message wording is honest
4133
4265
  // (thinking vs working, plus the longest-running in-flight tool).
@@ -8592,29 +8724,31 @@ async function handleInboundCoalesced(
8592
8724
  }
8593
8725
 
8594
8726
  const hasAttachment = downloadImage != null || attachment != null
8595
-
8596
- // Albums (media_group_id) are NOT coalesced in A1 — each part keeps its
8597
- // own turn exactly as before. The single-attachment merge can carry only
8598
- // one image, so folding a 3-photo album into one turn requires the
8599
- // multi-attachment inbound payload (the A2 follow-on). Bypass to preserve
8600
- // current per-part behavior and avoid dropping sibling photos.
8601
- if (hasAttachment && ctx.message?.media_group_id != null) {
8727
+ const maxAttachments = coalesceMaxAttachments()
8728
+
8729
+ // Albums (media_group_id): coalesce only when the cap allows >1 attachment
8730
+ // (A2). At the default cap of 1 each album part keeps its own turn exactly
8731
+ // as before the single-attachment merge can't carry sibling photos, so
8732
+ // bypassing avoids dropping them. With a raised cap the parts share the
8733
+ // coalesce key and fold into one multi-attachment turn (the cap-overflow
8734
+ // bypass below catches parts past the cap).
8735
+ if (hasAttachment && ctx.message?.media_group_id != null && maxAttachments <= 1) {
8602
8736
  return handleInbound(ctx, text, downloadImage, attachment)
8603
8737
  }
8604
8738
 
8605
8739
  const from = ctx.from
8606
8740
  if (!from) return
8607
8741
 
8608
- // A second attachment landing in an already-open window would clobber the
8609
- // first under the single-attachment merge. Bypass it to its own turn so no
8610
- // media is silently dropped; A2's multi-attachment payload lifts this.
8742
+ // An attachment past the per-agent cap would be dropped by the capped merge.
8743
+ // Bypass it to its own turn so no media is silently lost. At the default
8744
+ // cap of 1 this fires on the SECOND attachment, preserving A1 behaviour.
8611
8745
  if (hasAttachment) {
8612
8746
  const probeKey = inboundCoalesceKey(
8613
8747
  String(ctx.chat!.id),
8614
8748
  ctx.message?.message_thread_id,
8615
8749
  String(from.id),
8616
8750
  )
8617
- if (bufferedAttachmentKeys.has(probeKey)) {
8751
+ if ((bufferedAttachmentKeys.get(probeKey) ?? 0) >= maxAttachments) {
8618
8752
  return handleInbound(ctx, text, downloadImage, attachment)
8619
8753
  }
8620
8754
  }
@@ -8651,9 +8785,10 @@ async function handleInboundCoalesced(
8651
8785
  // Coalescing disabled (window <= 0): flush immediately, preserving any
8652
8786
  // media this message carried.
8653
8787
  if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
8654
- // Mark the open window as holding an attachment so a second attachment for
8655
- // this key bypasses rather than clobbers (cleared in onFlush).
8656
- if (hasAttachment) bufferedAttachmentKeys.add(key)
8788
+ // Count the open window's attachments so a third+ (or second, at the
8789
+ // default cap) bypasses rather than overflows the capped merge (cleared
8790
+ // in onFlush).
8791
+ if (hasAttachment) bufferedAttachmentKeys.set(key, (bufferedAttachmentKeys.get(key) ?? 0) + 1)
8657
8792
  }
8658
8793
 
8659
8794
  /**
@@ -8690,6 +8825,10 @@ async function handleInbound(
8690
8825
  text: string,
8691
8826
  downloadImage: (() => Promise<string | undefined>) | undefined,
8692
8827
  attachment?: AttachmentMeta,
8828
+ // A2: 2nd..Nth attachments folded into this coalesced turn. Each is
8829
+ // resolved (photos downloaded) and surfaced as numbered meta fields
8830
+ // (image_path_2, attachment_file_id_2, …) alongside the primary.
8831
+ extraAttachments?: CoalesceAttachment[],
8693
8832
  ): Promise<void> {
8694
8833
  const isTopicMessage = ctx.message?.is_topic_message ?? false
8695
8834
  const messageThreadId = ctx.message?.message_thread_id
@@ -8847,18 +8986,32 @@ async function handleInbound(
8847
8986
  // unauthorized senders never reach this code (gate() above).
8848
8987
  // Interrupt requires the same trust as sending a normal message.
8849
8988
  const interrupt = parseInterruptMarker(text)
8989
+ // Problem B: defer this `!`'s SIGINT to a safe boundary instead of firing it
8990
+ // synchronously below. Set only when the `interrupt.safe_boundary` flag is on
8991
+ // AND a top-level tool call is in flight AND the body is non-empty (an empty
8992
+ // `!` is an explicit halt-now and stays immediate). When set, we skip the
8993
+ // synchronous SIGINT here and stash the built inbound at the delivery site.
8994
+ let deferInterrupt = false
8850
8995
  if (interrupt.isInterrupt) {
8851
8996
  const agentName = process.env.SWITCHROOM_AGENT_NAME
8997
+ const access = loadAccess()
8998
+ deferInterrupt =
8999
+ !interrupt.emptyBody &&
9000
+ decideInterruptTiming({
9001
+ safeBoundaryEnabled: access.interruptSafeBoundary === true,
9002
+ midToolCall: toolFlightTracker.isMidToolCall(),
9003
+ }) === 'defer'
8852
9004
  process.stderr.write(
8853
9005
  `telegram gateway: interrupt-marker received chat_id=${chat_id} agent=${agentName ?? '-'} ` +
8854
- `body_len=${interrupt.body.length} empty=${interrupt.emptyBody}\n`,
9006
+ `body_len=${interrupt.body.length} empty=${interrupt.emptyBody} defer=${deferInterrupt} ` +
9007
+ `in_flight=${toolFlightTracker.inFlightCount()}\n`,
8855
9008
  )
8856
9009
  if (msgId != null) {
8857
9010
  void bot.api.setMessageReaction(chat_id, msgId, [
8858
9011
  { type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
8859
9012
  ]).catch(() => {})
8860
9013
  }
8861
- if (agentName) {
9014
+ if (agentName && !deferInterrupt) {
8862
9015
  try {
8863
9016
  // The gateway runs INSIDE the agent container in docker mode,
8864
9017
  // so calling `interruptAgent` (which probes `docker inspect`
@@ -9605,6 +9758,25 @@ async function handleInbound(
9605
9758
 
9606
9759
  const imagePath = downloadImage ? await downloadImage() : undefined
9607
9760
 
9761
+ // A2: resolve the extra attachments (2nd..Nth in a coalesced multi-media
9762
+ // burst). Photos are downloaded the same way as the primary; documents/
9763
+ // voice carry only attachment metadata (the agent fetches them via
9764
+ // download_attachment). Numbered meta fields below let the agent see each.
9765
+ const extraResolved: Array<{ imagePath?: string; attachment?: AttachmentMeta }> = []
9766
+ if (extraAttachments && extraAttachments.length > 0) {
9767
+ for (const ex of extraAttachments) {
9768
+ const exImagePath = ex.downloadImage ? await ex.downloadImage() : undefined
9769
+ extraResolved.push({ imagePath: exImagePath, attachment: ex.attachment })
9770
+ }
9771
+ }
9772
+ // Flatten the numbered meta fields once so the InboundMessage literal can
9773
+ // spread them. Primary is "1" (unsuffixed); extras start at "_2".
9774
+ const extraMeta = buildExtraAttachmentMeta(extraResolved)
9775
+ // Total attachment count (primary + extras) so the agent knows how many to
9776
+ // expect without probing for numbered fields. Only emitted when >1.
9777
+ const primaryHasAttachment = imagePath != null || attachment != null
9778
+ const attachmentCount = (primaryHasAttachment ? 1 : 0) + extraResolved.length
9779
+
9608
9780
  // Telegram-native reply context (issue #119). Same pattern as server.ts:
9609
9781
  // `replyToText` is raw (for SQLite); `replyToTextEscaped` is XML-escaped
9610
9782
  // (for channel meta).
@@ -9714,6 +9886,10 @@ async function handleInbound(
9714
9886
  ...(attachment.mime ? { attachment_mime: attachment.mime } : {}),
9715
9887
  ...(attachment.name ? { attachment_name: attachment.name } : {}),
9716
9888
  } : {}),
9889
+ // A2: numbered fields for the 2nd..Nth attachment + a total count so
9890
+ // the agent reads every item in a coalesced multi-media burst.
9891
+ ...(attachmentCount > 1 ? { attachment_count: String(attachmentCount) } : {}),
9892
+ ...extraMeta,
9717
9893
  },
9718
9894
  }
9719
9895
 
@@ -9745,6 +9921,40 @@ async function handleInbound(
9745
9921
  // line ~7357 already populated the Map for THIS inbound's turn;
9746
9922
  // reading the live size here would self-block (see the comment on
9747
9923
  // turnInFlightAtReceipt for the wedge symptom this fixes).
9924
+ // Problem B: a deferred `!` interrupt. The synchronous SIGINT was skipped
9925
+ // above (a tool was in flight) — claude is still working. Don't deliver the
9926
+ // replacement body now (it would race the live tool); stash the fully-built
9927
+ // inbound and let `fireDeferredInterrupt` SIGINT + deliver at the next clean
9928
+ // boundary, or when the max-wait timer expires. Rapid repeated `!` coalesce:
9929
+ // the latest body replaces the stashed inbound, the original deadline holds
9930
+ // so the wait stays bounded.
9931
+ if (deferInterrupt) {
9932
+ const selfAgentDefer = process.env.SWITCHROOM_AGENT_NAME ?? ''
9933
+ if (pendingDeferredInterrupt != null) {
9934
+ pendingDeferredInterrupt.inboundMsg = inboundMsg
9935
+ pendingDeferredInterrupt.msgId = msgId ?? null
9936
+ process.stderr.write(
9937
+ `telegram gateway: deferred-interrupt coalesced (replacing pending body) agent=${selfAgentDefer} chat=${chat_id} msg=${msgId ?? '-'}\n`,
9938
+ )
9939
+ } else {
9940
+ const maxWaitMs = resolveInterruptMaxWaitMs(loadAccess().interruptMaxWaitMs)
9941
+ pendingDeferredInterrupt = {
9942
+ agentName: selfAgentDefer,
9943
+ inboundMsg,
9944
+ chatId: chat_id,
9945
+ msgId: msgId ?? null,
9946
+ threadId: messageThreadId ?? undefined,
9947
+ registeredAt: Date.now(),
9948
+ deadlineTimer: setTimeout(() => { void fireDeferredInterrupt('timeout') }, maxWaitMs),
9949
+ }
9950
+ process.stderr.write(
9951
+ `telegram gateway: deferred-interrupt parked agent=${selfAgentDefer} chat=${chat_id} ` +
9952
+ `msg=${msgId ?? '-'} max_wait_ms=${maxWaitMs} in_flight=${toolFlightTracker.inFlightCount()}\n`,
9953
+ )
9954
+ }
9955
+ return
9956
+ }
9957
+
9748
9958
  if (
9749
9959
  decideInboundDelivery({
9750
9960
  turnInFlight: turnInFlightAtReceipt,
@@ -0,0 +1,100 @@
1
+ // Problem B — deferred safe-boundary interrupt.
2
+ //
3
+ // A `!`-prefix interrupt SIGINTs the agent's in-flight turn (tmux C-c) and
4
+ // then resumes with the replacement body as a fresh turn. Firing the SIGINT
5
+ // the instant `!` arrives can land mid-tool-call — a C-c during a Write or a
6
+ // Bash leaves the tool's work half-done. `reference/steer-or-queue-mid-flight.md`
7
+ // names this exact anti-pattern: "Mid-tool-call is not 'amend time.'"
8
+ //
9
+ // We can't pause claude's internal loop (the unmodified-CLI constraint — the
10
+ // only levers are SIGINT via tmux and observing the session JSONL). But we CAN
11
+ // observe when a tool call starts and finishes, and defer the SIGINT to the
12
+ // next clean boundary. This module is the pure, deterministic core of that
13
+ // decision so it can be unit-tested without the gateway's IPC / timers.
14
+
15
+ /** The session-event shape this tracker cares about. A structural subset of
16
+ * the gateway's `SessionEvent` so tests don't need the full union. */
17
+ export interface FlightEvent {
18
+ kind: string
19
+ toolUseId?: string | null
20
+ }
21
+
22
+ /**
23
+ * Tracks top-level tool calls in flight for the CURRENT turn, keyed by
24
+ * toolUseId. A `tool_use` adds; its matching `tool_result` removes; a
25
+ * `turn_end` or a fresh `enqueue` clears the slate (a new turn starts clean,
26
+ * and a killed turn may never emit the trailing `tool_result`).
27
+ *
28
+ * Sub-agent events (`sub_agent_*`) are intentionally ignored: the parent's
29
+ * `Task` tool_use already sits in the set and represents the user-observable
30
+ * wait, so the sub-agent's own tool calls don't independently gate the
31
+ * boundary. Telegram-surface tools are NOT excluded — treating every in-flight
32
+ * tool as "unsafe to C-c" is the conservative call, and the max-wait bound
33
+ * keeps a stuck reply tool from stranding the interrupt.
34
+ */
35
+ export class ToolFlightTracker {
36
+ private readonly inFlight = new Set<string>()
37
+
38
+ onEvent(ev: FlightEvent): void {
39
+ switch (ev.kind) {
40
+ case 'tool_use':
41
+ if (typeof ev.toolUseId === 'string' && ev.toolUseId.length > 0) {
42
+ this.inFlight.add(ev.toolUseId)
43
+ }
44
+ break
45
+ case 'tool_result':
46
+ if (typeof ev.toolUseId === 'string' && ev.toolUseId.length > 0) {
47
+ this.inFlight.delete(ev.toolUseId)
48
+ }
49
+ break
50
+ case 'turn_end':
51
+ case 'enqueue':
52
+ this.inFlight.clear()
53
+ break
54
+ // dequeue / thinking / text / tool_label / sub_agent_* — no effect.
55
+ default:
56
+ break
57
+ }
58
+ }
59
+
60
+ /** True when at least one top-level tool call is open (unsafe boundary). */
61
+ isMidToolCall(): boolean {
62
+ return this.inFlight.size > 0
63
+ }
64
+
65
+ /** Count of in-flight tool calls — exposed for diagnostics/logging. */
66
+ inFlightCount(): number {
67
+ return this.inFlight.size
68
+ }
69
+
70
+ clear(): void {
71
+ this.inFlight.clear()
72
+ }
73
+ }
74
+
75
+ export type InterruptTiming = 'fire-now' | 'defer'
76
+
77
+ /**
78
+ * Decide whether a `!` interrupt should fire immediately or wait for a safe
79
+ * boundary. Pure: the gateway feeds the live flag + tracker reading.
80
+ *
81
+ * - flag off → fire-now (historical synchronous behaviour)
82
+ * - flag on, no tool in flight → fire-now (already at a clean boundary)
83
+ * - flag on, tool in flight → defer (wait for tool_result / turn_end)
84
+ */
85
+ export function decideInterruptTiming(opts: {
86
+ safeBoundaryEnabled: boolean
87
+ midToolCall: boolean
88
+ }): InterruptTiming {
89
+ if (!opts.safeBoundaryEnabled) return 'fire-now'
90
+ return opts.midToolCall ? 'defer' : 'fire-now'
91
+ }
92
+
93
+ /** Floor for the deferred-interrupt max-wait. A non-positive or absent config
94
+ * value falls back to the default; we never wait forever. */
95
+ export const DEFAULT_INTERRUPT_MAX_WAIT_MS = 8000
96
+
97
+ export function resolveInterruptMaxWaitMs(configured: number | undefined): number {
98
+ if (typeof configured === 'number' && configured > 0) return configured
99
+ return DEFAULT_INTERRUPT_MAX_WAIT_MS
100
+ }
@@ -184,10 +184,16 @@ export function planBufferedRedelivery(
184
184
  return out
185
185
  }
186
186
 
187
+ /** Meta keys that describe an attachment — the primary (image_path,
188
+ * attachment_*) plus the A2 numbered siblings (image_path_2,
189
+ * attachment_file_id_2, …) and attachment_count. */
190
+ const ATTACHMENT_META_RE = /^(image_path|attachment_)/
191
+
187
192
  /** Collapse a >1 run into a single turn. The newest message anchors the
188
193
  * turn (its messageId/ts/user/meta); texts join in arrival order; the
189
- * single attachment (if any) rides along from whichever message carried
190
- * it. Caller guarantees the run is mergeable + has at most one media. */
194
+ * attachment(s) (if any) ride along from whichever message carried them.
195
+ * Caller guarantees the run is mergeable + has at most one media-bearing
196
+ * entry. */
191
197
  function mergeRun(run: InboundMessage[]): InboundMessage {
192
198
  const last = run[run.length - 1]!
193
199
  const mediaEntry = run.find(inboundHasMedia)
@@ -195,10 +201,21 @@ function mergeRun(run: InboundMessage[]): InboundMessage {
195
201
  ...last,
196
202
  text: run.map((m) => m.text).join('\n'),
197
203
  }
198
- // Re-seat the single attachment/imagePath from the entry that owns it
199
- // (which may not be `last`), or strip them if the run is text-only.
204
+ // Re-seat the attachment/imagePath from the entry that owns it (which may
205
+ // not be `last`), or strip them if the run is text-only.
200
206
  delete merged.imagePath
201
207
  delete merged.attachment
208
+ if (mediaEntry != null && mediaEntry !== last) {
209
+ // The media-bearing entry isn't the anchor, so `last.meta` lacks the
210
+ // attachment fields the agent reads (image_path / attachment_* and the
211
+ // A2 numbered siblings). Splice the owning entry's attachment meta keys
212
+ // into the merged meta so the agent still sees every attachment.
213
+ const splicedMeta: Record<string, string> = { ...merged.meta }
214
+ for (const [k, v] of Object.entries(mediaEntry.meta)) {
215
+ if (ATTACHMENT_META_RE.test(k)) splicedMeta[k] = v
216
+ }
217
+ merged.meta = splicedMeta
218
+ }
202
219
  if (mediaEntry?.imagePath != null) merged.imagePath = mediaEntry.imagePath
203
220
  if (mediaEntry?.attachment != null) merged.attachment = mediaEntry.attachment
204
221
  return merged