switchroom 0.14.19 → 0.14.21
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.
- package/dist/agent-scheduler/index.js +6 -1
- package/dist/auth-broker/index.js +6 -1
- package/dist/cli/notion-write-pretool.mjs +6 -1
- package/dist/cli/switchroom.js +17 -3
- package/dist/host-control/main.js +6 -1
- package/dist/vault/approvals/kernel-server.js +6 -1
- package/dist/vault/broker/server.js +6 -1
- package/package.json +2 -2
- package/telegram-plugin/README.md +7 -3
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +368 -153
- package/telegram-plugin/dist/server.js +1 -1
- package/telegram-plugin/gateway/coalesce-attachments.ts +79 -0
- package/telegram-plugin/gateway/gateway.ts +257 -39
- package/telegram-plugin/gateway/interrupt-defer.ts +106 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +21 -4
- package/telegram-plugin/tests/coalesce-attachments.test.ts +170 -0
- package/telegram-plugin/tests/interrupt-defer.test.ts +160 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +36 -0
- package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +86 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +127 -0
- package/telegram-plugin/uat/assertions.ts +53 -0
- package/telegram-plugin/uat/driver.ts +28 -0
- package/telegram-plugin/uat/feed-matcher.test.ts +80 -0
- package/telegram-plugin/uat/fixtures/album/blue.jpg +0 -0
- package/telegram-plugin/uat/fixtures/album/green.jpg +0 -0
- package/telegram-plugin/uat/fixtures/album/red.jpg +0 -0
- package/telegram-plugin/uat/scenarios/jtbd-album-coalescing-dm.test.ts +136 -0
- package/telegram-plugin/uat/scenarios/jtbd-forwarded-burst-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/jtbd-memory-survives-restart-dm.test.ts +17 -2
- package/telegram-plugin/worker-activity-feed.ts +65 -9
|
@@ -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,79 @@
|
|
|
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
|
+
/** Default attachments folded into one coalesced turn: a full Telegram album
|
|
40
|
+
* (media_group caps at 10). Floored at 1 so the only attachment is never
|
|
41
|
+
* stripped. Set channels.telegram.coalesce.max_attachments to override. */
|
|
42
|
+
export const DEFAULT_MAX_ATTACHMENTS = 10
|
|
43
|
+
|
|
44
|
+
export function resolveCoalesceMaxAttachments(configured: number | undefined): number {
|
|
45
|
+
return Math.max(1, configured ?? DEFAULT_MAX_ATTACHMENTS)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function splitCoalescedAttachments<T>(
|
|
49
|
+
entries: T[],
|
|
50
|
+
hasAttachment: (e: T) => boolean,
|
|
51
|
+
maxAttachments: number,
|
|
52
|
+
): { primary: T | undefined; extras: T[] } {
|
|
53
|
+
const withAttachment = entries.filter(hasAttachment)
|
|
54
|
+
const capped = withAttachment.slice(0, Math.max(1, maxAttachments))
|
|
55
|
+
const [primary, ...extras] = capped
|
|
56
|
+
return { primary, extras: extras }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the numbered meta fields for the resolved extra attachments. The
|
|
61
|
+
* primary occupies the unsuffixed keys, so extras start at `_2`.
|
|
62
|
+
*/
|
|
63
|
+
export function buildExtraAttachmentMeta(
|
|
64
|
+
resolved: ResolvedExtraAttachment[],
|
|
65
|
+
): Record<string, string> {
|
|
66
|
+
const out: Record<string, string> = {}
|
|
67
|
+
resolved.forEach((ex, i) => {
|
|
68
|
+
const n = i + 2
|
|
69
|
+
if (ex.imagePath) out[`image_path_${n}`] = ex.imagePath
|
|
70
|
+
if (ex.attachment) {
|
|
71
|
+
out[`attachment_kind_${n}`] = ex.attachment.kind
|
|
72
|
+
out[`attachment_file_id_${n}`] = ex.attachment.file_id
|
|
73
|
+
if (ex.attachment.size != null) out[`attachment_size_${n}`] = String(ex.attachment.size)
|
|
74
|
+
if (ex.attachment.mime) out[`attachment_mime_${n}`] = ex.attachment.mime
|
|
75
|
+
if (ex.attachment.name) out[`attachment_name_${n}`] = ex.attachment.name
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
@@ -35,6 +35,12 @@ 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
|
+
resolveSafeBoundaryEnabled,
|
|
43
|
+
} from './interrupt-defer.js'
|
|
38
44
|
import {
|
|
39
45
|
resolveStickerSendArgs,
|
|
40
46
|
resolveGifSendArgs,
|
|
@@ -51,9 +57,14 @@ import {
|
|
|
51
57
|
} from '../telegraph.js'
|
|
52
58
|
import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
53
59
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
60
|
+
import {
|
|
61
|
+
splitCoalescedAttachments,
|
|
62
|
+
buildExtraAttachmentMeta,
|
|
63
|
+
resolveCoalesceMaxAttachments,
|
|
64
|
+
} from './coalesce-attachments.js'
|
|
54
65
|
import { StatusReactionController } from '../status-reactions.js'
|
|
55
66
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
56
|
-
import { createWorkerActivityFeed } from '../worker-activity-feed.js'
|
|
67
|
+
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
57
68
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
58
69
|
import { appendActivityLabel } from '../tool-activity-summary.js'
|
|
59
70
|
import { toolLabel } from '../tool-labels.js'
|
|
@@ -770,6 +781,20 @@ type Access = {
|
|
|
770
781
|
parseMode?: 'html' | 'markdownv2' | 'text'
|
|
771
782
|
disableLinkPreview?: boolean
|
|
772
783
|
coalescingGapMs?: number
|
|
784
|
+
/** A2: max media attachments folded into one coalesced turn. Default 10
|
|
785
|
+
* (a full Telegram album / forwarded burst arrives as one turn). Set 1 to
|
|
786
|
+
* restore single-attachment behaviour. Projected from
|
|
787
|
+
* channels.telegram.coalesce.max_attachments by scaffold. */
|
|
788
|
+
coalesceMaxAttachments?: number
|
|
789
|
+
/** Problem B: when true (the default), a `!` interrupt that lands
|
|
790
|
+
* mid-tool-call is deferred until the in-flight tool finishes (bounded by
|
|
791
|
+
* interruptMaxWaitMs) before SIGINT + resume. Set false to fire
|
|
792
|
+
* synchronously. Projected from channels.telegram.interrupt.safe_boundary. */
|
|
793
|
+
interruptSafeBoundary?: boolean
|
|
794
|
+
/** Upper bound (ms) to wait for a safe boundary before firing a deferred
|
|
795
|
+
* interrupt anyway. Default 8000. Projected from
|
|
796
|
+
* channels.telegram.interrupt.max_wait_ms. */
|
|
797
|
+
interruptMaxWaitMs?: number
|
|
773
798
|
statusReactions?: boolean
|
|
774
799
|
historyEnabled?: boolean
|
|
775
800
|
historyRetentionDays?: number
|
|
@@ -868,6 +893,9 @@ function readAccessFile(): Access {
|
|
|
868
893
|
parseMode: parsed.parseMode,
|
|
869
894
|
disableLinkPreview: parsed.disableLinkPreview,
|
|
870
895
|
coalescingGapMs: parsed.coalescingGapMs,
|
|
896
|
+
coalesceMaxAttachments: parsed.coalesceMaxAttachments,
|
|
897
|
+
interruptSafeBoundary: parsed.interruptSafeBoundary,
|
|
898
|
+
interruptMaxWaitMs: parsed.interruptMaxWaitMs,
|
|
871
899
|
statusReactions: parsed.statusReactions,
|
|
872
900
|
historyEnabled: parsed.historyEnabled,
|
|
873
901
|
historyRetentionDays: parsed.historyRetentionDays,
|
|
@@ -1380,6 +1408,78 @@ type CurrentTurn = {
|
|
|
1380
1408
|
|
|
1381
1409
|
let currentTurn: CurrentTurn | null = null
|
|
1382
1410
|
|
|
1411
|
+
// Problem B — deferred safe-boundary interrupt.
|
|
1412
|
+
//
|
|
1413
|
+
// `toolFlightTracker` mirrors the session-event stream to know whether a
|
|
1414
|
+
// top-level tool call is open right now (an unsafe point to SIGINT). When the
|
|
1415
|
+
// `interrupt.safe_boundary` flag is on and a `!` lands mid-tool-call, we don't
|
|
1416
|
+
// fire the SIGINT — we stash the fully-built replacement inbound here and fire
|
|
1417
|
+
// it (SIGINT + deliver) at the next clean boundary (tool_result drains the
|
|
1418
|
+
// last open tool, or turn_end), or when the max-wait timer expires. Rapid
|
|
1419
|
+
// repeated `!` while one is pending coalesce: the latest body replaces the
|
|
1420
|
+
// stashed inbound, the original deadline is preserved (bounded wait).
|
|
1421
|
+
const toolFlightTracker = new ToolFlightTracker()
|
|
1422
|
+
|
|
1423
|
+
interface PendingDeferredInterrupt {
|
|
1424
|
+
agentName: string
|
|
1425
|
+
inboundMsg: InboundMessage
|
|
1426
|
+
chatId: string
|
|
1427
|
+
msgId: number | null
|
|
1428
|
+
threadId: number | undefined
|
|
1429
|
+
registeredAt: number
|
|
1430
|
+
deadlineTimer: ReturnType<typeof setTimeout>
|
|
1431
|
+
}
|
|
1432
|
+
let pendingDeferredInterrupt: PendingDeferredInterrupt | null = null
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Fire a stashed deferred interrupt: SIGINT the (now safely-bounded) turn via
|
|
1436
|
+
* tmux, then deliver the replacement body as a fresh inbound — the same two
|
|
1437
|
+
* primitives the synchronous `!` path uses, just gated on a clean boundary.
|
|
1438
|
+
* Idempotent: nulls the slot and clears the timer before doing any work so a
|
|
1439
|
+
* boundary event and the timeout can't double-fire.
|
|
1440
|
+
*/
|
|
1441
|
+
async function fireDeferredInterrupt(reason: 'boundary' | 'timeout'): Promise<void> {
|
|
1442
|
+
const pending = pendingDeferredInterrupt
|
|
1443
|
+
if (pending == null) return
|
|
1444
|
+
pendingDeferredInterrupt = null
|
|
1445
|
+
clearTimeout(pending.deadlineTimer)
|
|
1446
|
+
|
|
1447
|
+
const waitedMs = Date.now() - pending.registeredAt
|
|
1448
|
+
process.stderr.write(
|
|
1449
|
+
`telegram gateway: deferred-interrupt firing reason=${reason} agent=${pending.agentName} ` +
|
|
1450
|
+
`chat=${pending.chatId} waited_ms=${waitedMs} in_flight=${toolFlightTracker.inFlightCount()}\n`,
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
try {
|
|
1454
|
+
const { sendAgentInterrupt } = await import('../../src/agents/tmux.js')
|
|
1455
|
+
const r = sendAgentInterrupt({ agentName: pending.agentName })
|
|
1456
|
+
if ('ok' in r) {
|
|
1457
|
+
process.stderr.write(
|
|
1458
|
+
`telegram gateway: deferred-interrupt SIGINT delivered via tmux send-keys agent=${pending.agentName}\n`,
|
|
1459
|
+
)
|
|
1460
|
+
} else {
|
|
1461
|
+
process.stderr.write(
|
|
1462
|
+
`telegram gateway: deferred-interrupt SIGINT via tmux failed agent=${pending.agentName}: ${r.error}\n`,
|
|
1463
|
+
)
|
|
1464
|
+
}
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
process.stderr.write(`telegram gateway: deferred-interrupt SIGINT failed: ${(err as Error).message}\n`)
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// Deliver the replacement body as a fresh turn to the freshly-killed
|
|
1470
|
+
// bridge — same sendToAgent + buffer-on-miss primitive the synchronous
|
|
1471
|
+
// interrupt carve-out uses at the handleInbound delivery site.
|
|
1472
|
+
const delivered = ipcServer.sendToAgent(pending.agentName, pending.inboundMsg)
|
|
1473
|
+
if (delivered) {
|
|
1474
|
+
markClaudeBusyForInbound(pending.inboundMsg)
|
|
1475
|
+
} else {
|
|
1476
|
+
pendingInboundBuffer.push(pending.agentName, pending.inboundMsg)
|
|
1477
|
+
process.stderr.write(
|
|
1478
|
+
`telegram gateway: deferred-interrupt body buffered (bridge miss) agent=${pending.agentName} chat=${pending.chatId}\n`,
|
|
1479
|
+
)
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1383
1483
|
// #549 fix — preamble suppression for the answer-stream path.
|
|
1384
1484
|
//
|
|
1385
1485
|
// Background: assistant text emitted before a tool_use is "preamble"
|
|
@@ -3014,28 +3114,43 @@ type AttachmentMeta = {
|
|
|
3014
3114
|
name?: string
|
|
3015
3115
|
}
|
|
3016
3116
|
|
|
3117
|
+
// One attachment slot carried by a coalesced message — primary or extra.
|
|
3118
|
+
type CoalesceAttachment = {
|
|
3119
|
+
downloadImage?: () => Promise<string | undefined>
|
|
3120
|
+
attachment?: AttachmentMeta
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3017
3123
|
// CoalescePayload is what the InboundCoalescer carries per buffered message.
|
|
3018
3124
|
// `ctx` must be the *latest* message's context (latest message_id, etc.) so
|
|
3019
3125
|
// the merge function picks the last entry's ctx.
|
|
3020
3126
|
//
|
|
3021
|
-
//
|
|
3022
|
-
//
|
|
3023
|
-
//
|
|
3024
|
-
//
|
|
3025
|
-
// `
|
|
3026
|
-
//
|
|
3127
|
+
// Each inbound Telegram message carries at most one attachment, so an enqueued
|
|
3128
|
+
// payload sets at most `downloadImage`/`attachment`. The merge collects every
|
|
3129
|
+
// attachment-bearing entry in the window (up to coalesce.max_attachments): the
|
|
3130
|
+
// first becomes the primary `downloadImage`/`attachment`, the rest ride along
|
|
3131
|
+
// in `extraAttachments` (A2). When the cap is 1 (default), the
|
|
3132
|
+
// handleInboundCoalesced guards still bypass a second attachment / album part
|
|
3133
|
+
// to its own turn, so the single-attachment behaviour is byte-for-byte
|
|
3134
|
+
// preserved.
|
|
3027
3135
|
type CoalescePayload = {
|
|
3028
3136
|
text: string
|
|
3029
3137
|
ctx: Context
|
|
3030
3138
|
downloadImage?: () => Promise<string | undefined>
|
|
3031
3139
|
attachment?: AttachmentMeta
|
|
3140
|
+
// Set only by `merge`: the 2nd..Nth attachments folded into this turn.
|
|
3141
|
+
extraAttachments?: CoalesceAttachment[]
|
|
3032
3142
|
}
|
|
3033
3143
|
|
|
3034
|
-
//
|
|
3035
|
-
// A
|
|
3036
|
-
//
|
|
3037
|
-
//
|
|
3038
|
-
|
|
3144
|
+
// Count of attachment-bearing entries currently buffered per coalesce key.
|
|
3145
|
+
// A new attachment for a key whose count has reached the per-agent cap
|
|
3146
|
+
// (coalesce.max_attachments, default 10) bypasses coalescing (see
|
|
3147
|
+
// handleInboundCoalesced) so no media is dropped past the cap. Cleared on
|
|
3148
|
+
// flush (below) and on the synchronous bypass path.
|
|
3149
|
+
const bufferedAttachmentKeys = new Map<string, number>()
|
|
3150
|
+
|
|
3151
|
+
function coalesceMaxAttachments(): number {
|
|
3152
|
+
return resolveCoalesceMaxAttachments(loadAccess().coalesceMaxAttachments)
|
|
3153
|
+
}
|
|
3039
3154
|
|
|
3040
3155
|
const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
3041
3156
|
// Read per-call from the access file so an operator-tuned
|
|
@@ -3047,21 +3162,36 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
3047
3162
|
gapMs: () => loadAccess().coalescingGapMs ?? 500,
|
|
3048
3163
|
merge: (entries) => {
|
|
3049
3164
|
const last = entries[entries.length - 1]
|
|
3050
|
-
//
|
|
3051
|
-
//
|
|
3052
|
-
//
|
|
3053
|
-
// text-only.
|
|
3054
|
-
const
|
|
3165
|
+
// Collect every attachment-bearing entry in arrival order. The first is
|
|
3166
|
+
// the primary (unsuffixed image_path/attachment_* meta); the remainder,
|
|
3167
|
+
// capped at max_attachments, become numbered extras. A [photo][text]
|
|
3168
|
+
// burst keeps its image even though the last entry is text-only.
|
|
3169
|
+
const { primary, extras } = splitCoalescedAttachments(
|
|
3170
|
+
entries,
|
|
3171
|
+
(e) => e.downloadImage != null || e.attachment != null,
|
|
3172
|
+
coalesceMaxAttachments(),
|
|
3173
|
+
)
|
|
3055
3174
|
return {
|
|
3056
|
-
|
|
3175
|
+
// Drop empty texts (e.g. caption-less album parts) so the join doesn't
|
|
3176
|
+
// emit blank lines between attachments.
|
|
3177
|
+
text: entries.map((e) => e.text).filter((t) => t.length > 0).join('\n'),
|
|
3057
3178
|
ctx: last.ctx,
|
|
3058
|
-
downloadImage:
|
|
3059
|
-
attachment:
|
|
3179
|
+
downloadImage: primary?.downloadImage,
|
|
3180
|
+
attachment: primary?.attachment,
|
|
3181
|
+
extraAttachments: extras.length > 0
|
|
3182
|
+
? extras.map((e) => ({ downloadImage: e.downloadImage, attachment: e.attachment }))
|
|
3183
|
+
: undefined,
|
|
3060
3184
|
}
|
|
3061
3185
|
},
|
|
3062
3186
|
onFlush: (key, merged) => {
|
|
3063
3187
|
bufferedAttachmentKeys.delete(key)
|
|
3064
|
-
void handleInbound(
|
|
3188
|
+
void handleInbound(
|
|
3189
|
+
merged.ctx,
|
|
3190
|
+
merged.text,
|
|
3191
|
+
merged.downloadImage,
|
|
3192
|
+
merged.attachment,
|
|
3193
|
+
merged.extraAttachments,
|
|
3194
|
+
)
|
|
3065
3195
|
},
|
|
3066
3196
|
})
|
|
3067
3197
|
|
|
@@ -4128,6 +4258,14 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4128
4258
|
const threadHint = msg.threadId != null ? String(msg.threadId) : undefined
|
|
4129
4259
|
progressDriver?.ingest(ev, chatHint, threadHint)
|
|
4130
4260
|
handleSessionEvent(ev)
|
|
4261
|
+
// Problem B: keep the deferred-interrupt boundary tracker in lockstep with
|
|
4262
|
+
// the session stream (tool_use opens, tool_result/turn_end close). If a `!`
|
|
4263
|
+
// interrupt is parked waiting for a clean boundary and this event drains
|
|
4264
|
+
// the last in-flight tool, fire it now rather than waiting out the timer.
|
|
4265
|
+
toolFlightTracker.onEvent(ev)
|
|
4266
|
+
if (pendingDeferredInterrupt != null && !toolFlightTracker.isMidToolCall()) {
|
|
4267
|
+
void fireDeferredInterrupt('boundary')
|
|
4268
|
+
}
|
|
4131
4269
|
// #1122 silence-poke: surface activity signals from the session
|
|
4132
4270
|
// stream so the 300s framework-fallback message wording is honest
|
|
4133
4271
|
// (thinking vs working, plus the longest-running in-flight tool).
|
|
@@ -8592,29 +8730,32 @@ async function handleInboundCoalesced(
|
|
|
8592
8730
|
}
|
|
8593
8731
|
|
|
8594
8732
|
const hasAttachment = downloadImage != null || attachment != null
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
//
|
|
8598
|
-
//
|
|
8599
|
-
// multi-attachment
|
|
8600
|
-
//
|
|
8601
|
-
|
|
8733
|
+
const maxAttachments = coalesceMaxAttachments()
|
|
8734
|
+
|
|
8735
|
+
// Albums (media_group_id): coalesce only when the cap allows >1 attachment
|
|
8736
|
+
// (A2). At the default cap of 10 the parts share the coalesce key and fold
|
|
8737
|
+
// into one multi-attachment turn (the cap-overflow bypass below catches
|
|
8738
|
+
// parts past the cap). With the cap lowered to 1 each album part keeps its
|
|
8739
|
+
// own turn — the single-attachment merge can't carry sibling photos, so
|
|
8740
|
+
// bypassing avoids dropping them.
|
|
8741
|
+
if (hasAttachment && ctx.message?.media_group_id != null && maxAttachments <= 1) {
|
|
8602
8742
|
return handleInbound(ctx, text, downloadImage, attachment)
|
|
8603
8743
|
}
|
|
8604
8744
|
|
|
8605
8745
|
const from = ctx.from
|
|
8606
8746
|
if (!from) return
|
|
8607
8747
|
|
|
8608
|
-
//
|
|
8609
|
-
//
|
|
8610
|
-
//
|
|
8748
|
+
// An attachment past the per-agent cap would be dropped by the capped merge.
|
|
8749
|
+
// Bypass it to its own turn so no media is silently lost. At the default
|
|
8750
|
+
// cap of 10 this fires on the 11th attachment; with the cap lowered to 1 it
|
|
8751
|
+
// fires on the SECOND, preserving A1 behaviour.
|
|
8611
8752
|
if (hasAttachment) {
|
|
8612
8753
|
const probeKey = inboundCoalesceKey(
|
|
8613
8754
|
String(ctx.chat!.id),
|
|
8614
8755
|
ctx.message?.message_thread_id,
|
|
8615
8756
|
String(from.id),
|
|
8616
8757
|
)
|
|
8617
|
-
if (bufferedAttachmentKeys.
|
|
8758
|
+
if ((bufferedAttachmentKeys.get(probeKey) ?? 0) >= maxAttachments) {
|
|
8618
8759
|
return handleInbound(ctx, text, downloadImage, attachment)
|
|
8619
8760
|
}
|
|
8620
8761
|
}
|
|
@@ -8651,9 +8792,10 @@ async function handleInboundCoalesced(
|
|
|
8651
8792
|
// Coalescing disabled (window <= 0): flush immediately, preserving any
|
|
8652
8793
|
// media this message carried.
|
|
8653
8794
|
if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
|
|
8654
|
-
//
|
|
8655
|
-
//
|
|
8656
|
-
|
|
8795
|
+
// Count the open window's attachments so any part past the cap (the 11th
|
|
8796
|
+
// at the default cap of 10, or the second when lowered to 1) bypasses
|
|
8797
|
+
// rather than overflows the capped merge (cleared in onFlush).
|
|
8798
|
+
if (hasAttachment) bufferedAttachmentKeys.set(key, (bufferedAttachmentKeys.get(key) ?? 0) + 1)
|
|
8657
8799
|
}
|
|
8658
8800
|
|
|
8659
8801
|
/**
|
|
@@ -8690,6 +8832,10 @@ async function handleInbound(
|
|
|
8690
8832
|
text: string,
|
|
8691
8833
|
downloadImage: (() => Promise<string | undefined>) | undefined,
|
|
8692
8834
|
attachment?: AttachmentMeta,
|
|
8835
|
+
// A2: 2nd..Nth attachments folded into this coalesced turn. Each is
|
|
8836
|
+
// resolved (photos downloaded) and surfaced as numbered meta fields
|
|
8837
|
+
// (image_path_2, attachment_file_id_2, …) alongside the primary.
|
|
8838
|
+
extraAttachments?: CoalesceAttachment[],
|
|
8693
8839
|
): Promise<void> {
|
|
8694
8840
|
const isTopicMessage = ctx.message?.is_topic_message ?? false
|
|
8695
8841
|
const messageThreadId = ctx.message?.message_thread_id
|
|
@@ -8847,18 +8993,32 @@ async function handleInbound(
|
|
|
8847
8993
|
// unauthorized senders never reach this code (gate() above).
|
|
8848
8994
|
// Interrupt requires the same trust as sending a normal message.
|
|
8849
8995
|
const interrupt = parseInterruptMarker(text)
|
|
8996
|
+
// Problem B: defer this `!`'s SIGINT to a safe boundary instead of firing it
|
|
8997
|
+
// synchronously below. Set only when the `interrupt.safe_boundary` flag is on
|
|
8998
|
+
// AND a top-level tool call is in flight AND the body is non-empty (an empty
|
|
8999
|
+
// `!` is an explicit halt-now and stays immediate). When set, we skip the
|
|
9000
|
+
// synchronous SIGINT here and stash the built inbound at the delivery site.
|
|
9001
|
+
let deferInterrupt = false
|
|
8850
9002
|
if (interrupt.isInterrupt) {
|
|
8851
9003
|
const agentName = process.env.SWITCHROOM_AGENT_NAME
|
|
9004
|
+
const access = loadAccess()
|
|
9005
|
+
deferInterrupt =
|
|
9006
|
+
!interrupt.emptyBody &&
|
|
9007
|
+
decideInterruptTiming({
|
|
9008
|
+
safeBoundaryEnabled: resolveSafeBoundaryEnabled(access.interruptSafeBoundary),
|
|
9009
|
+
midToolCall: toolFlightTracker.isMidToolCall(),
|
|
9010
|
+
}) === 'defer'
|
|
8852
9011
|
process.stderr.write(
|
|
8853
9012
|
`telegram gateway: interrupt-marker received chat_id=${chat_id} agent=${agentName ?? '-'} ` +
|
|
8854
|
-
`body_len=${interrupt.body.length} empty=${interrupt.emptyBody}
|
|
9013
|
+
`body_len=${interrupt.body.length} empty=${interrupt.emptyBody} defer=${deferInterrupt} ` +
|
|
9014
|
+
`in_flight=${toolFlightTracker.inFlightCount()}\n`,
|
|
8855
9015
|
)
|
|
8856
9016
|
if (msgId != null) {
|
|
8857
9017
|
void bot.api.setMessageReaction(chat_id, msgId, [
|
|
8858
9018
|
{ type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
|
|
8859
9019
|
]).catch(() => {})
|
|
8860
9020
|
}
|
|
8861
|
-
if (agentName) {
|
|
9021
|
+
if (agentName && !deferInterrupt) {
|
|
8862
9022
|
try {
|
|
8863
9023
|
// The gateway runs INSIDE the agent container in docker mode,
|
|
8864
9024
|
// so calling `interruptAgent` (which probes `docker inspect`
|
|
@@ -9605,6 +9765,25 @@ async function handleInbound(
|
|
|
9605
9765
|
|
|
9606
9766
|
const imagePath = downloadImage ? await downloadImage() : undefined
|
|
9607
9767
|
|
|
9768
|
+
// A2: resolve the extra attachments (2nd..Nth in a coalesced multi-media
|
|
9769
|
+
// burst). Photos are downloaded the same way as the primary; documents/
|
|
9770
|
+
// voice carry only attachment metadata (the agent fetches them via
|
|
9771
|
+
// download_attachment). Numbered meta fields below let the agent see each.
|
|
9772
|
+
const extraResolved: Array<{ imagePath?: string; attachment?: AttachmentMeta }> = []
|
|
9773
|
+
if (extraAttachments && extraAttachments.length > 0) {
|
|
9774
|
+
for (const ex of extraAttachments) {
|
|
9775
|
+
const exImagePath = ex.downloadImage ? await ex.downloadImage() : undefined
|
|
9776
|
+
extraResolved.push({ imagePath: exImagePath, attachment: ex.attachment })
|
|
9777
|
+
}
|
|
9778
|
+
}
|
|
9779
|
+
// Flatten the numbered meta fields once so the InboundMessage literal can
|
|
9780
|
+
// spread them. Primary is "1" (unsuffixed); extras start at "_2".
|
|
9781
|
+
const extraMeta = buildExtraAttachmentMeta(extraResolved)
|
|
9782
|
+
// Total attachment count (primary + extras) so the agent knows how many to
|
|
9783
|
+
// expect without probing for numbered fields. Only emitted when >1.
|
|
9784
|
+
const primaryHasAttachment = imagePath != null || attachment != null
|
|
9785
|
+
const attachmentCount = (primaryHasAttachment ? 1 : 0) + extraResolved.length
|
|
9786
|
+
|
|
9608
9787
|
// Telegram-native reply context (issue #119). Same pattern as server.ts:
|
|
9609
9788
|
// `replyToText` is raw (for SQLite); `replyToTextEscaped` is XML-escaped
|
|
9610
9789
|
// (for channel meta).
|
|
@@ -9714,6 +9893,10 @@ async function handleInbound(
|
|
|
9714
9893
|
...(attachment.mime ? { attachment_mime: attachment.mime } : {}),
|
|
9715
9894
|
...(attachment.name ? { attachment_name: attachment.name } : {}),
|
|
9716
9895
|
} : {}),
|
|
9896
|
+
// A2: numbered fields for the 2nd..Nth attachment + a total count so
|
|
9897
|
+
// the agent reads every item in a coalesced multi-media burst.
|
|
9898
|
+
...(attachmentCount > 1 ? { attachment_count: String(attachmentCount) } : {}),
|
|
9899
|
+
...extraMeta,
|
|
9717
9900
|
},
|
|
9718
9901
|
}
|
|
9719
9902
|
|
|
@@ -9745,6 +9928,40 @@ async function handleInbound(
|
|
|
9745
9928
|
// line ~7357 already populated the Map for THIS inbound's turn;
|
|
9746
9929
|
// reading the live size here would self-block (see the comment on
|
|
9747
9930
|
// turnInFlightAtReceipt for the wedge symptom this fixes).
|
|
9931
|
+
// Problem B: a deferred `!` interrupt. The synchronous SIGINT was skipped
|
|
9932
|
+
// above (a tool was in flight) — claude is still working. Don't deliver the
|
|
9933
|
+
// replacement body now (it would race the live tool); stash the fully-built
|
|
9934
|
+
// inbound and let `fireDeferredInterrupt` SIGINT + deliver at the next clean
|
|
9935
|
+
// boundary, or when the max-wait timer expires. Rapid repeated `!` coalesce:
|
|
9936
|
+
// the latest body replaces the stashed inbound, the original deadline holds
|
|
9937
|
+
// so the wait stays bounded.
|
|
9938
|
+
if (deferInterrupt) {
|
|
9939
|
+
const selfAgentDefer = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
9940
|
+
if (pendingDeferredInterrupt != null) {
|
|
9941
|
+
pendingDeferredInterrupt.inboundMsg = inboundMsg
|
|
9942
|
+
pendingDeferredInterrupt.msgId = msgId ?? null
|
|
9943
|
+
process.stderr.write(
|
|
9944
|
+
`telegram gateway: deferred-interrupt coalesced (replacing pending body) agent=${selfAgentDefer} chat=${chat_id} msg=${msgId ?? '-'}\n`,
|
|
9945
|
+
)
|
|
9946
|
+
} else {
|
|
9947
|
+
const maxWaitMs = resolveInterruptMaxWaitMs(loadAccess().interruptMaxWaitMs)
|
|
9948
|
+
pendingDeferredInterrupt = {
|
|
9949
|
+
agentName: selfAgentDefer,
|
|
9950
|
+
inboundMsg,
|
|
9951
|
+
chatId: chat_id,
|
|
9952
|
+
msgId: msgId ?? null,
|
|
9953
|
+
threadId: messageThreadId ?? undefined,
|
|
9954
|
+
registeredAt: Date.now(),
|
|
9955
|
+
deadlineTimer: setTimeout(() => { void fireDeferredInterrupt('timeout') }, maxWaitMs),
|
|
9956
|
+
}
|
|
9957
|
+
process.stderr.write(
|
|
9958
|
+
`telegram gateway: deferred-interrupt parked agent=${selfAgentDefer} chat=${chat_id} ` +
|
|
9959
|
+
`msg=${msgId ?? '-'} max_wait_ms=${maxWaitMs} in_flight=${toolFlightTracker.inFlightCount()}\n`,
|
|
9960
|
+
)
|
|
9961
|
+
}
|
|
9962
|
+
return
|
|
9963
|
+
}
|
|
9964
|
+
|
|
9748
9965
|
if (
|
|
9749
9966
|
decideInboundDelivery({
|
|
9750
9967
|
turnInFlight: turnInFlightAtReceipt,
|
|
@@ -17355,10 +17572,11 @@ void (async () => {
|
|
|
17355
17572
|
// and edits it in place as work happens (current tool + elapsed),
|
|
17356
17573
|
// finalizing on completion — the same "live, growing message"
|
|
17357
17574
|
// shape the main agent's answer uses, NOT card chrome (the pinned
|
|
17358
|
-
// card was deleted in #1126).
|
|
17575
|
+
// card was deleted in #1126). On by default (set
|
|
17576
|
+
// SWITCHROOM_WORKER_ACTIVITY_FEED=0 to disable); when ON it also
|
|
17359
17577
|
// supersedes the coarse 5-min bucket relay below to avoid
|
|
17360
17578
|
// double-surfacing the same progress beat.
|
|
17361
|
-
const workerFeedEnabled = process.env.SWITCHROOM_WORKER_ACTIVITY_FEED
|
|
17579
|
+
const workerFeedEnabled = isWorkerActivityFeedEnabled(process.env.SWITCHROOM_WORKER_ACTIVITY_FEED)
|
|
17362
17580
|
const workerActivityFeed = createWorkerActivityFeed({
|
|
17363
17581
|
bot: {
|
|
17364
17582
|
sendMessage: async (cid, text, sendOpts) => {
|
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
}
|
|
101
|
+
|
|
102
|
+
/** safe_boundary defaults ON: a `!` mid-tool-call is deferred to a clean
|
|
103
|
+
* boundary unless the operator explicitly sets it false. */
|
|
104
|
+
export function resolveSafeBoundaryEnabled(configured: boolean | undefined): boolean {
|
|
105
|
+
return configured !== false
|
|
106
|
+
}
|