switchroom 0.14.17 → 0.14.19

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.
@@ -11068,6 +11068,9 @@ var TelegramChannelSchema = exports_external.object({
11068
11068
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
11069
11069
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
11070
11070
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11071
+ coalesce: exports_external.object({
11072
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
11073
+ }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11071
11074
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
11072
11075
  webhook_dispatch: exports_external.object({
11073
11076
  github: exports_external.array(exports_external.object({
@@ -11068,6 +11068,9 @@ var TelegramChannelSchema = exports_external.object({
11068
11068
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
11069
11069
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
11070
11070
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11071
+ coalesce: exports_external.object({
11072
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
11073
+ }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11071
11074
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
11072
11075
  webhook_dispatch: exports_external.object({
11073
11076
  github: exports_external.array(exports_external.object({
@@ -11815,6 +11815,9 @@ var TelegramChannelSchema = exports_external.object({
11815
11815
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
11816
11816
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
11817
11817
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default \u2014 content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11818
+ coalesce: exports_external.object({
11819
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
11820
+ }).optional().describe("Inbound coalescing \u2014 how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11818
11821
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default \u2014 webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 \u2014 see #577.)"),
11819
11822
  webhook_dispatch: exports_external.object({
11820
11823
  github: exports_external.array(exports_external.object({
@@ -13632,6 +13632,9 @@ var init_schema = __esm(() => {
13632
13632
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
13633
13633
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
13634
13634
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default \u2014 content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
13635
+ coalesce: exports_external.object({
13636
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
13637
+ }).optional().describe("Inbound coalescing \u2014 how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
13635
13638
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default \u2014 webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 \u2014 see #577.)"),
13636
13639
  webhook_dispatch: exports_external.object({
13637
13640
  github: exports_external.array(exports_external.object({
@@ -49413,8 +49416,8 @@ var {
49413
49416
  } = import__.default;
49414
49417
 
49415
49418
  // src/build-info.ts
49416
- var VERSION = "0.14.17";
49417
- var COMMIT_SHA = "95c1d475";
49419
+ var VERSION = "0.14.19";
49420
+ var COMMIT_SHA = "21863276";
49418
49421
 
49419
49422
  // src/cli/agent.ts
49420
49423
  init_source();
@@ -50213,6 +50216,37 @@ a flood. Going quiet mid-work is fine \u2014 going quiet *instead* of
50213
50216
  acknowledging, or *instead* of an update at a real milestone, is the
50214
50217
  black box this exists to prevent.
50215
50218
 
50219
+ ### Formatting \u2014 make it scannable
50220
+
50221
+ \`reply\` and \`stream_reply\` render Markdown as Telegram HTML for you, so
50222
+ \`**bold**\` becomes bold and backtick-wrapped text becomes monospace. Use it.
50223
+
50224
+ - **A one- or two-line conversational reply needs almost no markup.** Keep
50225
+ bold for the single fact that matters, never for decoration. "on it, pulling
50226
+ the logs now" is already perfect.
50227
+ - **A multi-section message needs visual hierarchy or it reads as a flat
50228
+ wall.** When you group several blocks \u2014 a status update, a "where things
50229
+ stand", a summary with distinct buckets, or **the message you post before
50230
+ kicking off a sub-agent or worker** \u2014 give each section a **bold label on
50231
+ its own line** and separate sections with **one blank line**. A row of
50232
+ emoji and bullets at equal weight with no spacing is the plain-text dump to
50233
+ avoid; bold labels + blank lines are what let the eye find the structure.
50234
+ Example shape:
50235
+
50236
+ **Dispatching**
50237
+ Kicking off a worker to crawl the changelog.
50238
+
50239
+ **What it'll do**
50240
+ \u2022 pull every entry since v0.14
50241
+ \u2022 flag anything user-facing
50242
+
50243
+ **Back in** ~2 min with a synthesized summary.
50244
+ - Bullets stay one level deep \u2014 Telegram flattens nested lists awkwardly. Use
50245
+ backtick-wrapped \`inline code\` for filenames, commands, and identifiers.
50246
+ - Don't use Markdown headings (\`#\` / \`##\`) in a reply \u2014 bold the label
50247
+ instead (\`**Blockers**\`, not \`## Blockers\`). Keep lines short; long
50248
+ unwrapped lines are hard to read on a phone.
50249
+
50216
50250
  Every turn that answers a user message ends with a user-visible
50217
50251
  \`reply\` (or \`stream_reply\` done=true) \u2014 Telegram is all the user
50218
50252
  sees; your terminal output never reaches them.`;
@@ -52681,6 +52715,9 @@ function buildAccessJson2(agentConfig, telegramConfig, resolvedTopicId, userId)
52681
52715
  if (tg?.telegraph) {
52682
52716
  access.telegraph = tg.telegraph;
52683
52717
  }
52718
+ if (typeof tg?.coalesce?.window_ms === "number") {
52719
+ access.coalescingGapMs = tg.coalesce.window_ms;
52720
+ }
52684
52721
  return JSON.stringify(access, null, 2) + `
52685
52722
  `;
52686
52723
  }
@@ -13803,6 +13803,9 @@ var TelegramChannelSchema = exports_external.object({
13803
13803
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
13804
13804
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
13805
13805
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
13806
+ coalesce: exports_external.object({
13807
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
13808
+ }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
13806
13809
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
13807
13810
  webhook_dispatch: exports_external.object({
13808
13811
  github: exports_external.array(exports_external.object({
@@ -11383,6 +11383,9 @@ var init_schema = __esm(() => {
11383
11383
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
11384
11384
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
11385
11385
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11386
+ coalesce: exports_external.object({
11387
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
11388
+ }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11386
11389
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
11387
11390
  webhook_dispatch: exports_external.object({
11388
11391
  github: exports_external.array(exports_external.object({
@@ -11383,6 +11383,9 @@ var init_schema = __esm(() => {
11383
11383
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
11384
11384
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
11385
11385
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default — content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
11386
+ coalesce: exports_external.object({
11387
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
11388
+ }).optional().describe("Inbound coalescing — how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
11386
11389
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default — webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 — see #577.)"),
11387
11390
  webhook_dispatch: exports_external.object({
11388
11391
  github: exports_external.array(exports_external.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.17",
3
+ "version": "0.14.19",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,13 +34,14 @@ If both `queued` and `steering` are somehow present, `steering` wins (explicit o
34
34
  **Self-narrate the classification.** At the top of your reply for any `steering` or `queued` message, include a brief italic one-liner so the user can correct you — e.g. `_↪️ Treating as steer on the prior task_` or `_📥 Queued as a new task_`.
35
35
 
36
36
  **Formatting** (Telegram HTML — `reply` and `stream_reply` default to `format: "html"` and convert markdown for you):
37
- - Use **bold** sparingly for emphasis on key facts only
37
+ - In ordinary one-or-two-line conversational replies, keep **bold** light emphasis on key facts only, never decoration.
38
+ - **A multi-section message needs visual hierarchy or it reads as a flat wall.** When a reply groups several blocks — a status update, a "where things stand", a message announcing you're dispatching work, a summary with distinct buckets — give each section a **bold label on its own line** and separate sections with **one blank line**. Bold the label (e.g. `**✅ Done / running**`, `**🔲 Remaining**`, `**Next**`); the markdown→HTML converter renders `**label**` as bold, so the eye can find the structure. An emoji prefix with no bold leaves every line at equal weight — that's the plain-text dump to avoid. Bullet lines under a label are fine (`•` or `-`, one point per line).
38
39
  - Use `inline code` for filenames, commands, identifiers
39
40
  - Use ```fenced code blocks``` for multi-line code
40
- - Lists are fine; nested lists are not (Telegram flattens them awkwardly)
41
- - Don't use markdown headings (`##`) in replies — Telegram has no `<h1>` and they render as plain bold lines
42
- - Keep lines short — long unwrapped lines are hard to read on mobile
43
- - One idea per message when possible; the user can always ask for more
41
+ - Nested lists are not supported (Telegram flattens them awkwardly) — keep bullets one level deep.
42
+ - Don't use markdown headings (`##`) in replies — bold the label instead (`**Blockers**`, not `## Blockers`).
43
+ - Keep lines short — long unwrapped lines are hard to read on mobile.
44
+ - One idea per message for a quick exchange; a structured update can carry several ideas, but only when each sits under its own bold label with blank-line spacing between them.
44
45
 
45
46
  **Sound human, not AI.** The canonical list of AI-tells to avoid lives in `SOUL.md` under "Never". Apply those rules to every outbound message, not just long-form. For drafts above ~500 chars, or where you're unsure if the voice lands right, invoke the bundled `/humanizer` skill for a polish pass (it catalogues 29 patterns in detail). If `HUMANIZER_VOICE_FILE` is set and readable, treat its content as the user's personal voice template: match length, tone, vocabulary, and formatting habits described there. The user can generate one with `/humanizer-calibrate`.
46
47
 
@@ -23737,6 +23737,9 @@ var init_schema = __esm(() => {
23737
23737
  short_name: exports_external.string().optional().describe("Telegraph account display name. Defaults to the agent's slug. Used at " + "first-publish to lazily create the account; cached thereafter."),
23738
23738
  author_name: exports_external.string().optional().describe("Telegraph article byline. Defaults to soul.name when set.")
23739
23739
  }).optional().describe("Long-reply publishing via Telegraph (#579). When enabled, replies " + "above the threshold publish as a Telegraph article rendered in " + "Telegram via native Instant View. Off by default \u2014 content " + "residency is real for some personas (lawyer, health-coach with PHI). " + "Cascades from defaults.channels.telegram.telegraph. " + "(Migrated from per-agent root in #596.)"),
23740
+ coalesce: exports_external.object({
23741
+ window_ms: exports_external.number().int().nonnegative().optional().describe("Sliding-window (ms) for merging consecutive inbound messages from " + "the same sender+topic into ONE Claude turn. Each new message resets " + "the timer; the turn starts once the sender pauses for this long. " + "Catches forwarded bursts, pasted text the Telegram client split " + "into several messages, and mixed text+media forwards. Default 500. " + "Set 0 to disable (every message becomes its own turn). Raise for " + "users who think in multiple short messages; the trade-off is the " + "single-message turn start is delayed by this much (the \uD83D\uDC40 ack still " + "fires immediately, so perceived latency is unchanged).")
23742
+ }).optional().describe("Inbound coalescing \u2014 how the gateway groups rapid consecutive messages " + "into a single turn so a forwarded album or split paste doesn't fan out " + "into N separate turns. Cascades from defaults.channels.telegram.coalesce."),
23740
23743
  webhook_sources: exports_external.array(exports_external.enum(["github", "generic"])).optional().describe("External webhook sources allowed to ingest events into this agent's " + "log. POST /webhook/<agent>/<source> on the switchroom web server. " + "Each source has its own signature verification ('github' = " + "X-Hub-Signature-256 HMAC-SHA256, 'generic' = Bearer token). " + "Per-source secret read from ~/.switchroom/webhook-secrets.json " + "keyed by [agent][source]. Verified events append to " + "<agent>/telegram/webhook-events.jsonl for the agent to read on " + "demand. Off by default \u2014 webhook is the only untrusted-inbound " + "surface in the system, so opt-in is mandatory. " + "Cascades from defaults.channels.telegram.webhook_sources. " + "(Migrated from per-agent root in #596 \u2014 see #577.)"),
23741
23744
  webhook_dispatch: exports_external.object({
23742
23745
  github: exports_external.array(exports_external.object({
@@ -31642,6 +31645,7 @@ var REACTION_VARIANTS = {
31642
31645
  coding: ["\uD83D\uDC68\u200d\uD83D\uDCBB", "\u270d", "\u26a1"],
31643
31646
  web: ["\u26a1", "\uD83E\uDD14", "\uD83D\uDC4C"],
31644
31647
  compacting: ["\u270d", "\uD83E\uDD14", "\uD83D\uDC40"],
31648
+ awaiting: ["\uD83D\uDE4F", "\uD83E\uDD14", "\uD83D\uDC40"],
31645
31649
  done: ["\uD83D\uDC4D", "\uD83D\uDCAF", "\uD83C\uDF89"],
31646
31650
  error: ["\uD83D\uDE31", "\uD83D\uDE28", "\uD83E\uDD2F"],
31647
31651
  stallSoft: ["\uD83E\uDD71", "\uD83D\uDE34", "\uD83E\uDD14"],
@@ -31694,6 +31698,12 @@ class StatusReactionController {
31694
31698
  setCompacting() {
31695
31699
  this.scheduleState("compacting");
31696
31700
  }
31701
+ setAwaiting() {
31702
+ if (this.finished)
31703
+ return;
31704
+ this.scheduleState("awaiting", { immediate: true, skipStallReset: true });
31705
+ this.clearStallTimers();
31706
+ }
31697
31707
  setError() {
31698
31708
  this.scheduleState("error");
31699
31709
  }
@@ -46341,23 +46351,70 @@ function redeliverBufferedInbound(buffer, agent, send, spool) {
46341
46351
  const pending = buffer.drain(agent);
46342
46352
  let redelivered = 0;
46343
46353
  let rebuffered = 0;
46344
- for (const msg of pending) {
46354
+ for (const { merged, originals } of planBufferedRedelivery(pending)) {
46345
46355
  let delivered = false;
46346
46356
  try {
46347
- delivered = send(msg);
46357
+ delivered = send(merged);
46348
46358
  } catch {
46349
46359
  delivered = false;
46350
46360
  }
46351
46361
  if (delivered) {
46352
- redelivered++;
46353
- spool?.ack(msg);
46362
+ for (const o of originals)
46363
+ spool?.ack(o);
46364
+ redelivered += originals.length;
46354
46365
  } else {
46355
- buffer.push(agent, msg);
46356
- rebuffered++;
46366
+ for (const o of originals)
46367
+ buffer.push(agent, o);
46368
+ rebuffered += originals.length;
46357
46369
  }
46358
46370
  }
46359
46371
  return { drained: pending.length, redelivered, rebuffered };
46360
46372
  }
46373
+ function isMergeableUserInbound(msg) {
46374
+ return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null);
46375
+ }
46376
+ function inboundHasMedia(msg) {
46377
+ return msg.imagePath != null || msg.attachment != null;
46378
+ }
46379
+ function planBufferedRedelivery(pending) {
46380
+ const out = [];
46381
+ let run2 = [];
46382
+ let runHasMedia = false;
46383
+ const sameTarget = (a, b) => a.chatId === b.chatId && (a.threadId ?? null) === (b.threadId ?? null) && a.userId === b.userId;
46384
+ const flush = () => {
46385
+ if (run2.length === 0)
46386
+ return;
46387
+ out.push({ merged: run2.length === 1 ? run2[0] : mergeRun(run2), originals: run2 });
46388
+ run2 = [];
46389
+ runHasMedia = false;
46390
+ };
46391
+ for (const msg of pending) {
46392
+ const msgHasMedia = inboundHasMedia(msg);
46393
+ const canJoin = run2.length > 0 && isMergeableUserInbound(msg) && isMergeableUserInbound(run2[run2.length - 1]) && sameTarget(run2[run2.length - 1], msg) && !(runHasMedia && msgHasMedia);
46394
+ if (!canJoin)
46395
+ flush();
46396
+ run2.push(msg);
46397
+ runHasMedia = runHasMedia || msgHasMedia;
46398
+ }
46399
+ flush();
46400
+ return out;
46401
+ }
46402
+ function mergeRun(run2) {
46403
+ const last = run2[run2.length - 1];
46404
+ const mediaEntry = run2.find(inboundHasMedia);
46405
+ const merged = {
46406
+ ...last,
46407
+ text: run2.map((m) => m.text).join(`
46408
+ `)
46409
+ };
46410
+ delete merged.imagePath;
46411
+ delete merged.attachment;
46412
+ if (mediaEntry?.imagePath != null)
46413
+ merged.imagePath = mediaEntry.imagePath;
46414
+ if (mediaEntry?.attachment != null)
46415
+ merged.attachment = mediaEntry.attachment;
46416
+ return merged;
46417
+ }
46361
46418
  function idleDrainTick(buffer, agent, isBridgeAlive, send, spool) {
46362
46419
  if (!agent)
46363
46420
  return null;
@@ -46932,23 +46989,70 @@ function redeliverBufferedInbound2(buffer, agent, send, spool) {
46932
46989
  const pending = buffer.drain(agent);
46933
46990
  let redelivered = 0;
46934
46991
  let rebuffered = 0;
46935
- for (const msg of pending) {
46992
+ for (const { merged, originals } of planBufferedRedelivery2(pending)) {
46936
46993
  let delivered = false;
46937
46994
  try {
46938
- delivered = send(msg);
46995
+ delivered = send(merged);
46939
46996
  } catch {
46940
46997
  delivered = false;
46941
46998
  }
46942
46999
  if (delivered) {
46943
- redelivered++;
46944
- spool?.ack(msg);
47000
+ for (const o of originals)
47001
+ spool?.ack(o);
47002
+ redelivered += originals.length;
46945
47003
  } else {
46946
- buffer.push(agent, msg);
46947
- rebuffered++;
47004
+ for (const o of originals)
47005
+ buffer.push(agent, o);
47006
+ rebuffered += originals.length;
46948
47007
  }
46949
47008
  }
46950
47009
  return { drained: pending.length, redelivered, rebuffered };
46951
47010
  }
47011
+ function isMergeableUserInbound2(msg) {
47012
+ return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null);
47013
+ }
47014
+ function inboundHasMedia2(msg) {
47015
+ return msg.imagePath != null || msg.attachment != null;
47016
+ }
47017
+ function planBufferedRedelivery2(pending) {
47018
+ const out = [];
47019
+ let run2 = [];
47020
+ let runHasMedia = false;
47021
+ const sameTarget = (a, b) => a.chatId === b.chatId && (a.threadId ?? null) === (b.threadId ?? null) && a.userId === b.userId;
47022
+ const flush = () => {
47023
+ if (run2.length === 0)
47024
+ return;
47025
+ out.push({ merged: run2.length === 1 ? run2[0] : mergeRun2(run2), originals: run2 });
47026
+ run2 = [];
47027
+ runHasMedia = false;
47028
+ };
47029
+ for (const msg of pending) {
47030
+ const msgHasMedia = inboundHasMedia2(msg);
47031
+ const canJoin = run2.length > 0 && isMergeableUserInbound2(msg) && isMergeableUserInbound2(run2[run2.length - 1]) && sameTarget(run2[run2.length - 1], msg) && !(runHasMedia && msgHasMedia);
47032
+ if (!canJoin)
47033
+ flush();
47034
+ run2.push(msg);
47035
+ runHasMedia = runHasMedia || msgHasMedia;
47036
+ }
47037
+ flush();
47038
+ return out;
47039
+ }
47040
+ function mergeRun2(run2) {
47041
+ const last = run2[run2.length - 1];
47042
+ const mediaEntry = run2.find(inboundHasMedia2);
47043
+ const merged = {
47044
+ ...last,
47045
+ text: run2.map((m) => m.text).join(`
47046
+ `)
47047
+ };
47048
+ delete merged.imagePath;
47049
+ delete merged.attachment;
47050
+ if (mediaEntry?.imagePath != null)
47051
+ merged.imagePath = mediaEntry.imagePath;
47052
+ if (mediaEntry?.attachment != null)
47053
+ merged.attachment = mediaEntry.attachment;
47054
+ return merged;
47055
+ }
46952
47056
 
46953
47057
  // gateway/inbound-delivery-machine-dispatch.ts
46954
47058
  var enabled6 = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== "0";
@@ -51140,10 +51244,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51140
51244
  }
51141
51245
 
51142
51246
  // ../src/build-info.ts
51143
- var VERSION = "0.14.17";
51144
- var COMMIT_SHA = "95c1d475";
51145
- var COMMIT_DATE = "2026-05-30T05:17:47Z";
51146
- var LATEST_PR = 2005;
51247
+ var VERSION = "0.14.19";
51248
+ var COMMIT_SHA = "21863276";
51249
+ var COMMIT_DATE = "2026-05-31T00:15:08Z";
51250
+ var LATEST_PR = 2013;
51147
51251
  var COMMITS_AHEAD_OF_TAG = 0;
51148
51252
 
51149
51253
  // gateway/boot-version.ts
@@ -52361,6 +52465,12 @@ function countRunningWorkers() {
52361
52465
  }
52362
52466
  return n;
52363
52467
  }
52468
+ function resumeReactionAfterVerdict() {
52469
+ const turn = currentTurn;
52470
+ if (turn == null)
52471
+ return;
52472
+ activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))?.setThinking();
52473
+ }
52364
52474
  function resolveThreadId(chat_id, explicit) {
52365
52475
  if (explicit != null)
52366
52476
  return Number(explicit);
@@ -52794,6 +52904,7 @@ var pendingStateReaper = setInterval(() => {
52794
52904
  for (const [k, v] of pendingPermissions) {
52795
52905
  if (now - v.startedAt > PERMISSION_TTL_MS) {
52796
52906
  dispatchPermissionVerdict({ type: "permission", requestId: k, behavior: "deny" });
52907
+ resumeReactionAfterVerdict();
52797
52908
  process.stderr.write(`telegram gateway: permission TTL expired \u2014 auto-deny request=${k} tool=${v.tool_name} (no operator response in ${Math.round(PERMISSION_TTL_MS / 60000)}m)
52798
52909
  `);
52799
52910
  pendingPermissions.delete(k);
@@ -52852,19 +52963,22 @@ function looksLikeAuthCode(text) {
52852
52963
  return true;
52853
52964
  return false;
52854
52965
  }
52966
+ var bufferedAttachmentKeys = new Set;
52855
52967
  var inboundCoalescer = createInboundCoalescer({
52856
52968
  gapMs: () => loadAccess().coalescingGapMs ?? 500,
52857
52969
  merge: (entries) => {
52858
52970
  const last = entries[entries.length - 1];
52971
+ const withAttachment = entries.find((e) => e.downloadImage != null || e.attachment != null);
52859
52972
  return {
52860
52973
  text: entries.map((e) => e.text).join(`
52861
52974
  `),
52862
52975
  ctx: last.ctx,
52863
- downloadImage: last.downloadImage,
52864
- attachment: last.attachment
52976
+ downloadImage: withAttachment?.downloadImage,
52977
+ attachment: withAttachment?.attachment
52865
52978
  };
52866
52979
  },
52867
- onFlush: (_key, merged) => {
52980
+ onFlush: (key, merged) => {
52981
+ bufferedAttachmentKeys.delete(key);
52868
52982
  handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment);
52869
52983
  }
52870
52984
  });
@@ -53432,6 +53546,9 @@ var ipcServer = createIpcServer({
53432
53546
  `);
53433
53547
  });
53434
53548
  }
53549
+ if (activeTurn != null) {
53550
+ activeStatusReactions.get(statusKey(activeTurn.sessionChatId, activeTurn.sessionThreadId))?.setAwaiting();
53551
+ }
53435
53552
  },
53436
53553
  onHeartbeat(_client, _msg) {},
53437
53554
  onScheduleRestart(client3, msg) {
@@ -56020,19 +56137,29 @@ function safeName(s) {
56020
56137
  return s?.replace(/[<>\[\]\r\n;]/g, "_");
56021
56138
  }
56022
56139
  async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
56023
- if (downloadImage || attachment)
56024
- return handleInbound(ctx, text, downloadImage, attachment);
56025
56140
  if (parseInterruptMarker(text).isInterrupt) {
56026
- return handleInbound(ctx, text, undefined, undefined);
56141
+ return handleInbound(ctx, text, downloadImage, attachment);
56142
+ }
56143
+ const hasAttachment = downloadImage != null || attachment != null;
56144
+ if (hasAttachment && ctx.message?.media_group_id != null) {
56145
+ return handleInbound(ctx, text, downloadImage, attachment);
56027
56146
  }
56028
56147
  const from = ctx.from;
56029
56148
  if (!from)
56030
56149
  return;
56150
+ if (hasAttachment) {
56151
+ const probeKey = inboundCoalesceKey(String(ctx.chat.id), ctx.message?.message_thread_id, String(from.id));
56152
+ if (bufferedAttachmentKeys.has(probeKey)) {
56153
+ return handleInbound(ctx, text, downloadImage, attachment);
56154
+ }
56155
+ }
56031
56156
  maybeEarlyAckReaction(ctx, from);
56032
56157
  const key = inboundCoalesceKey(String(ctx.chat.id), ctx.message?.message_thread_id, String(from.id));
56033
56158
  const result = inboundCoalescer.enqueue(key, { text, ctx, downloadImage, attachment });
56034
56159
  if (result.bypass)
56035
- return handleInbound(ctx, text, undefined, undefined);
56160
+ return handleInbound(ctx, text, downloadImage, attachment);
56161
+ if (hasAttachment)
56162
+ bufferedAttachmentKeys.add(key);
56036
56163
  }
56037
56164
  function maybeEarlyAckReaction(ctx, from) {
56038
56165
  const msgId = ctx.message?.message_id;
@@ -56173,6 +56300,7 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
56173
56300
  requestId: request_id,
56174
56301
  behavior
56175
56302
  });
56303
+ resumeReactionAfterVerdict();
56176
56304
  if (msgId != null) {
56177
56305
  const emoji = behavior === "allow" ? "\u2705" : "\u274C";
56178
56306
  bot.api.setMessageReaction(chat_id, msgId, [
@@ -57878,6 +58006,7 @@ async function handlePermissionSlash(ctx, behavior) {
57878
58006
  return;
57879
58007
  }
57880
58008
  dispatchPermissionVerdict({ type: "permission", requestId: request_id, behavior });
58009
+ resumeReactionAfterVerdict();
57881
58010
  pendingPermissions.delete(request_id);
57882
58011
  process.stderr.write(`[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}
57883
58012
  `);
@@ -60174,6 +60303,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
60174
60303
  behavior: "allow",
60175
60304
  rule: chosen.rule
60176
60305
  });
60306
+ resumeReactionAfterVerdict();
60177
60307
  let durable = false;
60178
60308
  let legacy = false;
60179
60309
  let failReason = "";
@@ -60275,7 +60405,9 @@ ${editLabel}` : editLabel,
60275
60405
  return;
60276
60406
  }
60277
60407
  pendingPermissions.delete(request_id);
60278
- const label = behavior === "allow" ? "\u2705 Allowed" : "\u274C Denied";
60408
+ const resumeAgent = process.env.SWITCHROOM_AGENT_NAME;
60409
+ const resumeBeat = resumeAgent ? `\u25B6\uFE0F ${escapeHtmlForTg(resumeAgent)} resuming\u2026` : "\u25B6\uFE0F resuming\u2026";
60410
+ const label = `${behavior === "allow" ? "\u2705 Allowed" : "\u274C Denied"} \xB7 ${resumeBeat}`;
60279
60411
  const msg = ctx.callbackQuery?.message;
60280
60412
  const baseText = msg && "text" in msg && msg.text ? escapeHtmlForTg(msg.text) : "";
60281
60413
  await finalizeCallback(ctx, {
@@ -60290,6 +60422,7 @@ ${label}` : label,
60290
60422
  requestId: request_id,
60291
60423
  behavior
60292
60424
  });
60425
+ resumeReactionAfterVerdict();
60293
60426
  }
60294
60427
  });
60295
60428
  });
@@ -60298,7 +60431,7 @@ bot.on("message:text", async (ctx) => {
60298
60431
  });
60299
60432
  bot.on("message:photo", async (ctx) => {
60300
60433
  const caption = ctx.message.caption ?? "(photo)";
60301
- await handleInbound(ctx, caption, async () => {
60434
+ await handleInboundCoalesced(ctx, caption, async () => {
60302
60435
  const photos = ctx.message.photo;
60303
60436
  const best = photos[photos.length - 1];
60304
60437
  try {
@@ -60334,7 +60467,7 @@ bot.on("message:photo", async (ctx) => {
60334
60467
  bot.on("message:document", async (ctx) => {
60335
60468
  const doc = ctx.message.document;
60336
60469
  const name = safeName(doc.file_name);
60337
- await handleInbound(ctx, ctx.message.caption ?? `(document: ${name ?? "file"})`, undefined, { kind: "document", file_id: doc.file_id, size: doc.file_size, mime: doc.mime_type, name });
60470
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? `(document: ${name ?? "file"})`, undefined, { kind: "document", file_id: doc.file_id, size: doc.file_size, mime: doc.mime_type, name });
60338
60471
  });
60339
60472
  bot.on("message:voice", async (ctx) => {
60340
60473
  const voice = ctx.message.voice;
@@ -60346,7 +60479,7 @@ bot.on("message:voice", async (ctx) => {
60346
60479
  const text = ctx.message.caption ? `${ctx.message.caption}
60347
60480
 
60348
60481
  [voice transcript] ${transcript}` : `[voice transcript] ${transcript}`;
60349
- await handleInbound(ctx, text, undefined, {
60482
+ await handleInboundCoalesced(ctx, text, undefined, {
60350
60483
  kind: "voice",
60351
60484
  file_id: voice.file_id,
60352
60485
  size: voice.file_size,
@@ -60355,7 +60488,7 @@ bot.on("message:voice", async (ctx) => {
60355
60488
  return;
60356
60489
  }
60357
60490
  }
60358
- await handleInbound(ctx, ctx.message.caption ?? "(voice message)", undefined, { kind: "voice", file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type });
60491
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? "(voice message)", undefined, { kind: "voice", file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type });
60359
60492
  });
60360
60493
  async function maybeTranscribeVoice(fileId, mimeType, language) {
60361
60494
  let apiKey = null;
@@ -60415,15 +60548,15 @@ async function maybeTranscribeVoice(fileId, mimeType, language) {
60415
60548
  bot.on("message:audio", async (ctx) => {
60416
60549
  const audio = ctx.message.audio;
60417
60550
  const name = safeName(audio.file_name);
60418
- await handleInbound(ctx, ctx.message.caption ?? `(audio: ${safeName(audio.title) ?? name ?? "audio"})`, undefined, { kind: "audio", file_id: audio.file_id, size: audio.file_size, mime: audio.mime_type, name });
60551
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? `(audio: ${safeName(audio.title) ?? name ?? "audio"})`, undefined, { kind: "audio", file_id: audio.file_id, size: audio.file_size, mime: audio.mime_type, name });
60419
60552
  });
60420
60553
  bot.on("message:video", async (ctx) => {
60421
60554
  const video = ctx.message.video;
60422
- await handleInbound(ctx, ctx.message.caption ?? "(video)", undefined, { kind: "video", file_id: video.file_id, size: video.file_size, mime: video.mime_type, name: safeName(video.file_name) });
60555
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? "(video)", undefined, { kind: "video", file_id: video.file_id, size: video.file_size, mime: video.mime_type, name: safeName(video.file_name) });
60423
60556
  });
60424
60557
  bot.on("message:video_note", async (ctx) => {
60425
60558
  const vn = ctx.message.video_note;
60426
- await handleInbound(ctx, "(video note)", undefined, { kind: "video_note", file_id: vn.file_id, size: vn.file_size });
60559
+ await handleInboundCoalesced(ctx, "(video note)", undefined, { kind: "video_note", file_id: vn.file_id, size: vn.file_size });
60427
60560
  });
60428
60561
  bot.on("message:sticker", async (ctx) => {
60429
60562
  const sticker = ctx.message.sticker;
@@ -60433,13 +60566,13 @@ bot.on("message:sticker", async (ctx) => {
60433
60566
  if (sticker.set_name)
60434
60567
  parts.push(`from "${sticker.set_name}"`);
60435
60568
  const text = parts.length > 0 ? `(sticker \u2014 ${parts.join(" ")})` : "(sticker)";
60436
- await handleInbound(ctx, text, undefined, { kind: "sticker", file_id: sticker.file_id, size: sticker.file_size });
60569
+ await handleInboundCoalesced(ctx, text, undefined, { kind: "sticker", file_id: sticker.file_id, size: sticker.file_size });
60437
60570
  });
60438
60571
  bot.on("message:animation", async (ctx) => {
60439
60572
  const animation = ctx.message.animation;
60440
60573
  const caption = ctx.message.caption;
60441
60574
  const text = caption ? `(gif) ${caption}` : "(gif)";
60442
- await handleInbound(ctx, text, undefined, {
60575
+ await handleInboundCoalesced(ctx, text, undefined, {
60443
60576
  kind: "animation",
60444
60577
  file_id: animation.file_id,
60445
60578
  size: animation.file_size,