switchroom 0.14.16 → 0.14.18

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.16";
49417
- var COMMIT_SHA = "6f5d9562";
49419
+ var VERSION = "0.14.18";
49420
+ var COMMIT_SHA = "dddb8617";
49418
49421
 
49419
49422
  // src/cli/agent.ts
49420
49423
  init_source();
@@ -52681,6 +52684,9 @@ function buildAccessJson2(agentConfig, telegramConfig, resolvedTopicId, userId)
52681
52684
  if (tg?.telegraph) {
52682
52685
  access.telegraph = tg.telegraph;
52683
52686
  }
52687
+ if (typeof tg?.coalesce?.window_ms === "number") {
52688
+ access.coalescingGapMs = tg.coalesce.window_ms;
52689
+ }
52684
52690
  return JSON.stringify(access, null, 2) + `
52685
52691
  `;
52686
52692
  }
@@ -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.16",
3
+ "version": "0.14.18",
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({
@@ -46341,23 +46344,70 @@ function redeliverBufferedInbound(buffer, agent, send, spool) {
46341
46344
  const pending = buffer.drain(agent);
46342
46345
  let redelivered = 0;
46343
46346
  let rebuffered = 0;
46344
- for (const msg of pending) {
46347
+ for (const { merged, originals } of planBufferedRedelivery(pending)) {
46345
46348
  let delivered = false;
46346
46349
  try {
46347
- delivered = send(msg);
46350
+ delivered = send(merged);
46348
46351
  } catch {
46349
46352
  delivered = false;
46350
46353
  }
46351
46354
  if (delivered) {
46352
- redelivered++;
46353
- spool?.ack(msg);
46355
+ for (const o of originals)
46356
+ spool?.ack(o);
46357
+ redelivered += originals.length;
46354
46358
  } else {
46355
- buffer.push(agent, msg);
46356
- rebuffered++;
46359
+ for (const o of originals)
46360
+ buffer.push(agent, o);
46361
+ rebuffered += originals.length;
46357
46362
  }
46358
46363
  }
46359
46364
  return { drained: pending.length, redelivered, rebuffered };
46360
46365
  }
46366
+ function isMergeableUserInbound(msg) {
46367
+ return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null);
46368
+ }
46369
+ function inboundHasMedia(msg) {
46370
+ return msg.imagePath != null || msg.attachment != null;
46371
+ }
46372
+ function planBufferedRedelivery(pending) {
46373
+ const out = [];
46374
+ let run2 = [];
46375
+ let runHasMedia = false;
46376
+ const sameTarget = (a, b) => a.chatId === b.chatId && (a.threadId ?? null) === (b.threadId ?? null) && a.userId === b.userId;
46377
+ const flush = () => {
46378
+ if (run2.length === 0)
46379
+ return;
46380
+ out.push({ merged: run2.length === 1 ? run2[0] : mergeRun(run2), originals: run2 });
46381
+ run2 = [];
46382
+ runHasMedia = false;
46383
+ };
46384
+ for (const msg of pending) {
46385
+ const msgHasMedia = inboundHasMedia(msg);
46386
+ const canJoin = run2.length > 0 && isMergeableUserInbound(msg) && isMergeableUserInbound(run2[run2.length - 1]) && sameTarget(run2[run2.length - 1], msg) && !(runHasMedia && msgHasMedia);
46387
+ if (!canJoin)
46388
+ flush();
46389
+ run2.push(msg);
46390
+ runHasMedia = runHasMedia || msgHasMedia;
46391
+ }
46392
+ flush();
46393
+ return out;
46394
+ }
46395
+ function mergeRun(run2) {
46396
+ const last = run2[run2.length - 1];
46397
+ const mediaEntry = run2.find(inboundHasMedia);
46398
+ const merged = {
46399
+ ...last,
46400
+ text: run2.map((m) => m.text).join(`
46401
+ `)
46402
+ };
46403
+ delete merged.imagePath;
46404
+ delete merged.attachment;
46405
+ if (mediaEntry?.imagePath != null)
46406
+ merged.imagePath = mediaEntry.imagePath;
46407
+ if (mediaEntry?.attachment != null)
46408
+ merged.attachment = mediaEntry.attachment;
46409
+ return merged;
46410
+ }
46361
46411
  function idleDrainTick(buffer, agent, isBridgeAlive, send, spool) {
46362
46412
  if (!agent)
46363
46413
  return null;
@@ -46932,23 +46982,70 @@ function redeliverBufferedInbound2(buffer, agent, send, spool) {
46932
46982
  const pending = buffer.drain(agent);
46933
46983
  let redelivered = 0;
46934
46984
  let rebuffered = 0;
46935
- for (const msg of pending) {
46985
+ for (const { merged, originals } of planBufferedRedelivery2(pending)) {
46936
46986
  let delivered = false;
46937
46987
  try {
46938
- delivered = send(msg);
46988
+ delivered = send(merged);
46939
46989
  } catch {
46940
46990
  delivered = false;
46941
46991
  }
46942
46992
  if (delivered) {
46943
- redelivered++;
46944
- spool?.ack(msg);
46993
+ for (const o of originals)
46994
+ spool?.ack(o);
46995
+ redelivered += originals.length;
46945
46996
  } else {
46946
- buffer.push(agent, msg);
46947
- rebuffered++;
46997
+ for (const o of originals)
46998
+ buffer.push(agent, o);
46999
+ rebuffered += originals.length;
46948
47000
  }
46949
47001
  }
46950
47002
  return { drained: pending.length, redelivered, rebuffered };
46951
47003
  }
47004
+ function isMergeableUserInbound2(msg) {
47005
+ return msg.type === "inbound" && (msg.meta == null || msg.meta.source == null);
47006
+ }
47007
+ function inboundHasMedia2(msg) {
47008
+ return msg.imagePath != null || msg.attachment != null;
47009
+ }
47010
+ function planBufferedRedelivery2(pending) {
47011
+ const out = [];
47012
+ let run2 = [];
47013
+ let runHasMedia = false;
47014
+ const sameTarget = (a, b) => a.chatId === b.chatId && (a.threadId ?? null) === (b.threadId ?? null) && a.userId === b.userId;
47015
+ const flush = () => {
47016
+ if (run2.length === 0)
47017
+ return;
47018
+ out.push({ merged: run2.length === 1 ? run2[0] : mergeRun2(run2), originals: run2 });
47019
+ run2 = [];
47020
+ runHasMedia = false;
47021
+ };
47022
+ for (const msg of pending) {
47023
+ const msgHasMedia = inboundHasMedia2(msg);
47024
+ const canJoin = run2.length > 0 && isMergeableUserInbound2(msg) && isMergeableUserInbound2(run2[run2.length - 1]) && sameTarget(run2[run2.length - 1], msg) && !(runHasMedia && msgHasMedia);
47025
+ if (!canJoin)
47026
+ flush();
47027
+ run2.push(msg);
47028
+ runHasMedia = runHasMedia || msgHasMedia;
47029
+ }
47030
+ flush();
47031
+ return out;
47032
+ }
47033
+ function mergeRun2(run2) {
47034
+ const last = run2[run2.length - 1];
47035
+ const mediaEntry = run2.find(inboundHasMedia2);
47036
+ const merged = {
47037
+ ...last,
47038
+ text: run2.map((m) => m.text).join(`
47039
+ `)
47040
+ };
47041
+ delete merged.imagePath;
47042
+ delete merged.attachment;
47043
+ if (mediaEntry?.imagePath != null)
47044
+ merged.imagePath = mediaEntry.imagePath;
47045
+ if (mediaEntry?.attachment != null)
47046
+ merged.attachment = mediaEntry.attachment;
47047
+ return merged;
47048
+ }
46952
47049
 
46953
47050
  // gateway/inbound-delivery-machine-dispatch.ts
46954
47051
  var enabled6 = process.env.SWITCHROOM_DELIVERY_MACHINE_CUTOVER !== "0";
@@ -51140,10 +51237,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51140
51237
  }
51141
51238
 
51142
51239
  // ../src/build-info.ts
51143
- var VERSION = "0.14.16";
51144
- var COMMIT_SHA = "6f5d9562";
51145
- var COMMIT_DATE = "2026-05-30T04:37:39Z";
51146
- var LATEST_PR = 2003;
51240
+ var VERSION = "0.14.18";
51241
+ var COMMIT_SHA = "dddb8617";
51242
+ var COMMIT_DATE = "2026-05-30T23:35:26Z";
51243
+ var LATEST_PR = 2010;
51147
51244
  var COMMITS_AHEAD_OF_TAG = 0;
51148
51245
 
51149
51246
  // gateway/boot-version.ts
@@ -51605,6 +51702,34 @@ function applySubagentsSchema(db2) {
51605
51702
  }
51606
51703
  db2.exec("CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)");
51607
51704
  }
51705
+ function mapSubagentRow(row) {
51706
+ return {
51707
+ id: row.id,
51708
+ parent_session_id: row.parent_session_id,
51709
+ parent_turn_key: row.parent_turn_key,
51710
+ agent_type: row.agent_type,
51711
+ description: row.description,
51712
+ background: row.background !== 0,
51713
+ started_at: row.started_at,
51714
+ last_activity_at: row.last_activity_at,
51715
+ ended_at: row.ended_at,
51716
+ status: row.status,
51717
+ result_summary: row.result_summary,
51718
+ jsonl_agent_id: row.jsonl_agent_id
51719
+ };
51720
+ }
51721
+ function getSubagentByJsonlId(db2, jsonlAgentId) {
51722
+ const row = db2.prepare("SELECT * FROM subagents WHERE jsonl_agent_id = ?").get(jsonlAgentId);
51723
+ return row ? mapSubagentRow(row) : null;
51724
+ }
51725
+
51726
+ // gateway/worker-feed-dispatch.ts
51727
+ function resolveWorkerFeedDispatch(sub, watcherDescription) {
51728
+ return {
51729
+ isBackground: sub?.background ?? false,
51730
+ feedDescription: (sub?.description ?? "") || watcherDescription
51731
+ };
51732
+ }
51608
51733
 
51609
51734
  // gateway/resolve-calling-subagent.ts
51610
51735
  function resolveCallingSubagent(opts) {
@@ -52824,19 +52949,22 @@ function looksLikeAuthCode(text) {
52824
52949
  return true;
52825
52950
  return false;
52826
52951
  }
52952
+ var bufferedAttachmentKeys = new Set;
52827
52953
  var inboundCoalescer = createInboundCoalescer({
52828
52954
  gapMs: () => loadAccess().coalescingGapMs ?? 500,
52829
52955
  merge: (entries) => {
52830
52956
  const last = entries[entries.length - 1];
52957
+ const withAttachment = entries.find((e) => e.downloadImage != null || e.attachment != null);
52831
52958
  return {
52832
52959
  text: entries.map((e) => e.text).join(`
52833
52960
  `),
52834
52961
  ctx: last.ctx,
52835
- downloadImage: last.downloadImage,
52836
- attachment: last.attachment
52962
+ downloadImage: withAttachment?.downloadImage,
52963
+ attachment: withAttachment?.attachment
52837
52964
  };
52838
52965
  },
52839
- onFlush: (_key, merged) => {
52966
+ onFlush: (key, merged) => {
52967
+ bufferedAttachmentKeys.delete(key);
52840
52968
  handleInbound(merged.ctx, merged.text, merged.downloadImage, merged.attachment);
52841
52969
  }
52842
52970
  });
@@ -55992,19 +56120,29 @@ function safeName(s) {
55992
56120
  return s?.replace(/[<>\[\]\r\n;]/g, "_");
55993
56121
  }
55994
56122
  async function handleInboundCoalesced(ctx, text, downloadImage, attachment) {
55995
- if (downloadImage || attachment)
55996
- return handleInbound(ctx, text, downloadImage, attachment);
55997
56123
  if (parseInterruptMarker(text).isInterrupt) {
55998
- return handleInbound(ctx, text, undefined, undefined);
56124
+ return handleInbound(ctx, text, downloadImage, attachment);
56125
+ }
56126
+ const hasAttachment = downloadImage != null || attachment != null;
56127
+ if (hasAttachment && ctx.message?.media_group_id != null) {
56128
+ return handleInbound(ctx, text, downloadImage, attachment);
55999
56129
  }
56000
56130
  const from = ctx.from;
56001
56131
  if (!from)
56002
56132
  return;
56133
+ if (hasAttachment) {
56134
+ const probeKey = inboundCoalesceKey(String(ctx.chat.id), ctx.message?.message_thread_id, String(from.id));
56135
+ if (bufferedAttachmentKeys.has(probeKey)) {
56136
+ return handleInbound(ctx, text, downloadImage, attachment);
56137
+ }
56138
+ }
56003
56139
  maybeEarlyAckReaction(ctx, from);
56004
56140
  const key = inboundCoalesceKey(String(ctx.chat.id), ctx.message?.message_thread_id, String(from.id));
56005
56141
  const result = inboundCoalescer.enqueue(key, { text, ctx, downloadImage, attachment });
56006
56142
  if (result.bypass)
56007
- return handleInbound(ctx, text, undefined, undefined);
56143
+ return handleInbound(ctx, text, downloadImage, attachment);
56144
+ if (hasAttachment)
56145
+ bufferedAttachmentKeys.add(key);
56008
56146
  }
56009
56147
  function maybeEarlyAckReaction(ctx, from) {
56010
56148
  const msgId = ctx.message?.message_id;
@@ -60270,7 +60408,7 @@ bot.on("message:text", async (ctx) => {
60270
60408
  });
60271
60409
  bot.on("message:photo", async (ctx) => {
60272
60410
  const caption = ctx.message.caption ?? "(photo)";
60273
- await handleInbound(ctx, caption, async () => {
60411
+ await handleInboundCoalesced(ctx, caption, async () => {
60274
60412
  const photos = ctx.message.photo;
60275
60413
  const best = photos[photos.length - 1];
60276
60414
  try {
@@ -60306,7 +60444,7 @@ bot.on("message:photo", async (ctx) => {
60306
60444
  bot.on("message:document", async (ctx) => {
60307
60445
  const doc = ctx.message.document;
60308
60446
  const name = safeName(doc.file_name);
60309
- 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 });
60447
+ 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 });
60310
60448
  });
60311
60449
  bot.on("message:voice", async (ctx) => {
60312
60450
  const voice = ctx.message.voice;
@@ -60318,7 +60456,7 @@ bot.on("message:voice", async (ctx) => {
60318
60456
  const text = ctx.message.caption ? `${ctx.message.caption}
60319
60457
 
60320
60458
  [voice transcript] ${transcript}` : `[voice transcript] ${transcript}`;
60321
- await handleInbound(ctx, text, undefined, {
60459
+ await handleInboundCoalesced(ctx, text, undefined, {
60322
60460
  kind: "voice",
60323
60461
  file_id: voice.file_id,
60324
60462
  size: voice.file_size,
@@ -60327,7 +60465,7 @@ bot.on("message:voice", async (ctx) => {
60327
60465
  return;
60328
60466
  }
60329
60467
  }
60330
- await handleInbound(ctx, ctx.message.caption ?? "(voice message)", undefined, { kind: "voice", file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type });
60468
+ await handleInboundCoalesced(ctx, ctx.message.caption ?? "(voice message)", undefined, { kind: "voice", file_id: voice.file_id, size: voice.file_size, mime: voice.mime_type });
60331
60469
  });
60332
60470
  async function maybeTranscribeVoice(fileId, mimeType, language) {
60333
60471
  let apiKey = null;
@@ -60387,15 +60525,15 @@ async function maybeTranscribeVoice(fileId, mimeType, language) {
60387
60525
  bot.on("message:audio", async (ctx) => {
60388
60526
  const audio = ctx.message.audio;
60389
60527
  const name = safeName(audio.file_name);
60390
- 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 });
60528
+ 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 });
60391
60529
  });
60392
60530
  bot.on("message:video", async (ctx) => {
60393
60531
  const video = ctx.message.video;
60394
- 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) });
60532
+ 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) });
60395
60533
  });
60396
60534
  bot.on("message:video_note", async (ctx) => {
60397
60535
  const vn = ctx.message.video_note;
60398
- await handleInbound(ctx, "(video note)", undefined, { kind: "video_note", file_id: vn.file_id, size: vn.file_size });
60536
+ await handleInboundCoalesced(ctx, "(video note)", undefined, { kind: "video_note", file_id: vn.file_id, size: vn.file_size });
60399
60537
  });
60400
60538
  bot.on("message:sticker", async (ctx) => {
60401
60539
  const sticker = ctx.message.sticker;
@@ -60405,13 +60543,13 @@ bot.on("message:sticker", async (ctx) => {
60405
60543
  if (sticker.set_name)
60406
60544
  parts.push(`from "${sticker.set_name}"`);
60407
60545
  const text = parts.length > 0 ? `(sticker \u2014 ${parts.join(" ")})` : "(sticker)";
60408
- await handleInbound(ctx, text, undefined, { kind: "sticker", file_id: sticker.file_id, size: sticker.file_size });
60546
+ await handleInboundCoalesced(ctx, text, undefined, { kind: "sticker", file_id: sticker.file_id, size: sticker.file_size });
60409
60547
  });
60410
60548
  bot.on("message:animation", async (ctx) => {
60411
60549
  const animation = ctx.message.animation;
60412
60550
  const caption = ctx.message.caption;
60413
60551
  const text = caption ? `(gif) ${caption}` : "(gif)";
60414
- await handleInbound(ctx, text, undefined, {
60552
+ await handleInboundCoalesced(ctx, text, undefined, {
60415
60553
  kind: "animation",
60416
60554
  file_id: animation.file_id,
60417
60555
  size: animation.file_size,
@@ -61332,8 +61470,6 @@ var didOneTimeSetup = false;
61332
61470
  onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs }) => {
61333
61471
  deferredDoneReactions.promote();
61334
61472
  let fleetChatId = "";
61335
- let isBackground = false;
61336
- let dispatchDesc = "";
61337
61473
  try {
61338
61474
  const fleets = progressDriver?.peekAllFleets() ?? [];
61339
61475
  for (const f of fleets) {
@@ -61343,18 +61479,16 @@ var didOneTimeSetup = false;
61343
61479
  }
61344
61480
  }
61345
61481
  } catch {}
61482
+ let dispatch = resolveWorkerFeedDispatch(null, description);
61346
61483
  if (turnsDb != null) {
61347
61484
  try {
61348
- const row = turnsDb.prepare("SELECT background, description FROM subagents WHERE jsonl_agent_id = ?").get(agentId);
61349
- if (row != null) {
61350
- isBackground = row.background === 1;
61351
- dispatchDesc = row.description ?? "";
61352
- }
61485
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description);
61353
61486
  } catch {}
61354
61487
  }
61488
+ const isBackground = dispatch.isBackground;
61355
61489
  if (workerFeedEnabled) {
61356
61490
  workerActivityFeed.finish(agentId, {
61357
- description: dispatchDesc || description,
61491
+ description: dispatch.feedDescription,
61358
61492
  lastTool: null,
61359
61493
  toolCount,
61360
61494
  latestSummary: resultText,
@@ -61396,8 +61530,6 @@ var didOneTimeSetup = false;
61396
61530
  },
61397
61531
  onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
61398
61532
  let fleetChatId = "";
61399
- let isBackground = false;
61400
- let dispatchDesc = "";
61401
61533
  try {
61402
61534
  const fleets = progressDriver?.peekAllFleets() ?? [];
61403
61535
  for (const f of fleets) {
@@ -61407,20 +61539,18 @@ var didOneTimeSetup = false;
61407
61539
  }
61408
61540
  }
61409
61541
  } catch {}
61542
+ let dispatch = resolveWorkerFeedDispatch(null, description);
61410
61543
  if (turnsDb != null) {
61411
61544
  try {
61412
- const row = turnsDb.prepare("SELECT background, description FROM subagents WHERE jsonl_agent_id = ?").get(agentId);
61413
- if (row != null) {
61414
- isBackground = row.background === 1;
61415
- dispatchDesc = row.description ?? "";
61416
- }
61545
+ dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description);
61417
61546
  } catch {}
61418
61547
  }
61548
+ const isBackground = dispatch.isBackground;
61419
61549
  if (!isBackground)
61420
61550
  return;
61421
61551
  if (workerFeedEnabled) {
61422
61552
  workerActivityFeed.update(agentId, fleetChatId || (loadAccess().allowFrom[0] ?? ""), {
61423
- description: dispatchDesc || description,
61553
+ description: dispatch.feedDescription,
61424
61554
  lastTool,
61425
61555
  toolCount,
61426
61556
  latestSummary,