switchroom 0.14.20 → 0.14.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11070,10 +11070,10 @@ var TelegramChannelSchema = exports_external.object({
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
11071
  coalesce: exports_external.object({
11072
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
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 — a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, …). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
11073
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
11074
11074
  }).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."),
11075
11075
  interrupt: exports_external.object({
11076
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
11076
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
11077
11077
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
11078
11078
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
11079
11079
  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.)"),
@@ -11070,10 +11070,10 @@ var TelegramChannelSchema = exports_external.object({
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
11071
  coalesce: exports_external.object({
11072
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
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 — a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, …). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
11073
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
11074
11074
  }).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."),
11075
11075
  interrupt: exports_external.object({
11076
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
11076
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
11077
11077
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
11078
11078
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
11079
11079
  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.)"),
@@ -11817,10 +11817,10 @@ var TelegramChannelSchema = exports_external.object({
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
11818
  coalesce: exports_external.object({
11819
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
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 \u2014 a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
11820
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 \u2014 a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
11821
11821
  }).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."),
11822
11822
  interrupt: exports_external.object({
11823
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false \u2014 the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
11823
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
11824
11824
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short \u2014 the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
11825
11825
  }).optional().describe("Interrupt timing \u2014 how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
11826
11826
  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.)"),
@@ -13634,10 +13634,10 @@ var init_schema = __esm(() => {
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
13635
  coalesce: exports_external.object({
13636
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
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 \u2014 a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
13637
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 \u2014 a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
13638
13638
  }).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."),
13639
13639
  interrupt: exports_external.object({
13640
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false \u2014 the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
13640
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
13641
13641
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short \u2014 the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
13642
13642
  }).optional().describe("Interrupt timing \u2014 how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
13643
13643
  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.)"),
@@ -49421,8 +49421,8 @@ var {
49421
49421
  } = import__.default;
49422
49422
 
49423
49423
  // src/build-info.ts
49424
- var VERSION = "0.14.20";
49425
- var COMMIT_SHA = "c8b965b2";
49424
+ var VERSION = "0.14.21";
49425
+ var COMMIT_SHA = "62ddded0";
49426
49426
 
49427
49427
  // src/cli/agent.ts
49428
49428
  init_source();
@@ -13805,10 +13805,10 @@ var TelegramChannelSchema = exports_external.object({
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
13806
  coalesce: exports_external.object({
13807
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
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 — a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, …). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
13808
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
13809
13809
  }).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."),
13810
13810
  interrupt: exports_external.object({
13811
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
13811
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
13812
13812
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
13813
13813
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
13814
13814
  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.)"),
@@ -11385,10 +11385,10 @@ var init_schema = __esm(() => {
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
11386
  coalesce: exports_external.object({
11387
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
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 — a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, …). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
11388
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
11389
11389
  }).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."),
11390
11390
  interrupt: exports_external.object({
11391
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
11391
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
11392
11392
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
11393
11393
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
11394
11394
  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.)"),
@@ -11385,10 +11385,10 @@ var init_schema = __esm(() => {
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
11386
  coalesce: exports_external.object({
11387
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
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 — a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, …). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
11388
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 — a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, …). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
11389
11389
  }).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."),
11390
11390
  interrupt: exports_external.object({
11391
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
11391
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
11392
11392
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short — the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
11393
11393
  }).optional().describe("Interrupt timing — how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
11394
11394
  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.)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.20",
3
+ "version": "0.14.21",
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": {
@@ -25,7 +25,7 @@
25
25
  "pretest": "npm run build",
26
26
  "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
27
27
  "test:vitest": "vitest run",
28
- "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
28
+ "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts",
29
29
  "test:watch": "vitest",
30
30
  "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs && node scripts/check-no-broadcast-delivery.mjs",
31
31
  "lint:tsc": "tsc --noEmit",
@@ -23739,10 +23739,10 @@ var init_schema = __esm(() => {
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
23740
  coalesce: exports_external.object({
23741
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
- max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 1 \u2014 a second photo/document/voice within the " + "coalesce window (or an album / media_group_id) starts its own turn, " + "preserving the historical single-attachment behaviour. Raise to let " + "a forwarded album or a text+multi-image burst arrive as one turn; " + "the agent then sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Excess attachments beyond the cap spill into the " + "next turn. Each attachment is downloaded, so a high cap on a slow " + "link delays turn start.")
23742
+ max_attachments: exports_external.number().int().positive().optional().describe("Maximum number of media attachments carried into ONE coalesced " + "Claude turn. Default 10 \u2014 a full Telegram album (media_group caps " + "at 10) or a text+multi-image forwarded burst arrives as a single " + "turn; the agent sees numbered attachment fields (image_path, " + "image_path_2, \u2026). Set 1 to restore the historical " + "single-attachment-per-turn behaviour. Excess attachments beyond " + "the cap spill into the next turn. Each attachment is downloaded, " + "so a high cap on a slow link delays turn start.")
23743
23743
  }).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."),
23744
23744
  interrupt: exports_external.object({
23745
- safe_boundary: exports_external.boolean().optional().describe("When true, a `!`-prefix interrupt that arrives while the agent is " + "mid-tool-call is DEFERRED: the SIGINT and the replacement turn wait " + "until the in-flight tool call finishes (a clean boundary) instead of " + "C-c'ing the agent mid-write/mid-bash. If no tool is in flight the " + "interrupt still fires immediately. Bounded by max_wait_ms so a long " + "tool never strands the user. Default false \u2014 the interrupt fires " + "synchronously the moment `!` is received (historical behaviour). " + "Rapid repeated `!` while one is pending coalesce into a single " + "deferred interrupt carrying the latest body."),
23745
+ safe_boundary: exports_external.boolean().optional().describe("When true (the default), a `!`-prefix interrupt that arrives while " + "the agent is mid-tool-call is DEFERRED: the SIGINT and the " + "replacement turn wait until the in-flight tool call finishes (a " + "clean boundary) instead of C-c'ing the agent mid-write/mid-bash. If " + "no tool is in flight the interrupt still fires immediately. Bounded " + "by max_wait_ms so a long tool never strands the user. Set false to " + "fire synchronously the moment `!` is received (historical " + "behaviour). Rapid repeated `!` while one is pending coalesce into a " + "single deferred interrupt carrying the latest body."),
23746
23746
  max_wait_ms: exports_external.number().int().positive().optional().describe("Upper bound (ms) the gateway waits for a safe boundary before firing " + "a deferred `!` interrupt anyway. Only consulted when safe_boundary is " + "true. Default 8000. Keep it short \u2014 the user explicitly asked to " + "interrupt, so a long in-flight tool shouldn't ghost them; the cap " + "trades a tiny risk of a mid-tool C-c for a guaranteed response.")
23747
23747
  }).optional().describe("Interrupt timing \u2014 how a `!`-prefix interrupt behaves when it lands " + "mid-tool-call. Off by default (fire immediately). Cascades from " + "defaults.channels.telegram.interrupt."),
23748
23748
  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.)"),
@@ -31074,6 +31074,9 @@ function resolveInterruptMaxWaitMs(configured) {
31074
31074
  return configured;
31075
31075
  return DEFAULT_INTERRUPT_MAX_WAIT_MS;
31076
31076
  }
31077
+ function resolveSafeBoundaryEnabled(configured) {
31078
+ return configured !== false;
31079
+ }
31077
31080
 
31078
31081
  // sticker-aliases.ts
31079
31082
  function looksLikeFileId(s) {
@@ -31613,6 +31616,10 @@ function inboundCoalesceKey(chatId, threadId, userId) {
31613
31616
  }
31614
31617
 
31615
31618
  // gateway/coalesce-attachments.ts
31619
+ var DEFAULT_MAX_ATTACHMENTS = 10;
31620
+ function resolveCoalesceMaxAttachments(configured) {
31621
+ return Math.max(1, configured ?? DEFAULT_MAX_ATTACHMENTS);
31622
+ }
31616
31623
  function splitCoalescedAttachments(entries, hasAttachment, maxAttachments) {
31617
31624
  const withAttachment = entries.filter(hasAttachment);
31618
31625
  const capped = withAttachment.slice(0, Math.max(1, maxAttachments));
@@ -31956,6 +31963,9 @@ class DeferredDoneReactions {
31956
31963
  }
31957
31964
 
31958
31965
  // worker-activity-feed.ts
31966
+ function isWorkerActivityFeedEnabled(envVal) {
31967
+ return envVal !== "0";
31968
+ }
31959
31969
  var DESC_MAX = 80;
31960
31970
  var TOOL_ARG_MAX = 64;
31961
31971
  var SUMMARY_MAX = 100;
@@ -51360,10 +51370,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51360
51370
  }
51361
51371
 
51362
51372
  // ../src/build-info.ts
51363
- var VERSION = "0.14.20";
51364
- var COMMIT_SHA = "c8b965b2";
51365
- var COMMIT_DATE = "2026-05-31T01:51:10Z";
51366
- var LATEST_PR = 2018;
51373
+ var VERSION = "0.14.21";
51374
+ var COMMIT_SHA = "62ddded0";
51375
+ var COMMIT_DATE = "2026-05-31T02:54:29Z";
51376
+ var LATEST_PR = 2024;
51367
51377
  var COMMITS_AHEAD_OF_TAG = 0;
51368
51378
 
51369
51379
  // gateway/boot-version.ts
@@ -53118,7 +53128,7 @@ function looksLikeAuthCode(text) {
53118
53128
  }
53119
53129
  var bufferedAttachmentKeys = new Map;
53120
53130
  function coalesceMaxAttachments() {
53121
- return Math.max(1, loadAccess().coalesceMaxAttachments ?? 1);
53131
+ return resolveCoalesceMaxAttachments(loadAccess().coalesceMaxAttachments);
53122
53132
  }
53123
53133
  var inboundCoalescer = createInboundCoalescer({
53124
53134
  gapMs: () => loadAccess().coalescingGapMs ?? 500,
@@ -56407,7 +56417,7 @@ async function handleInbound(ctx, text, downloadImage, attachment, extraAttachme
56407
56417
  const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
56408
56418
  const access2 = loadAccess();
56409
56419
  deferInterrupt = !interrupt.emptyBody && decideInterruptTiming({
56410
- safeBoundaryEnabled: access2.interruptSafeBoundary === true,
56420
+ safeBoundaryEnabled: resolveSafeBoundaryEnabled(access2.interruptSafeBoundary),
56411
56421
  midToolCall: toolFlightTracker.isMidToolCall()
56412
56422
  }) === "defer";
56413
56423
  process.stderr.write(`telegram gateway: interrupt-marker received chat_id=${chat_id} agent=${agentName3 ?? "-"} body_len=${interrupt.body.length} empty=${interrupt.emptyBody} defer=${deferInterrupt} in_flight=${toolFlightTracker.inFlightCount()}
@@ -61654,7 +61664,7 @@ var didOneTimeSetup = false;
61654
61664
  if (streamMode === "checklist") {
61655
61665
  const watcherAgentDir = resolveAgentDirFromEnv();
61656
61666
  if (watcherAgentDir != null) {
61657
- const workerFeedEnabled = process.env.SWITCHROOM_WORKER_ACTIVITY_FEED === "1";
61667
+ const workerFeedEnabled = isWorkerActivityFeedEnabled(process.env.SWITCHROOM_WORKER_ACTIVITY_FEED);
61658
61668
  const workerActivityFeed = createWorkerActivityFeed({
61659
61669
  bot: {
61660
61670
  sendMessage: async (cid, text, sendOpts) => {
@@ -36,6 +36,15 @@ export interface ResolvedExtraAttachment {
36
36
  * `maxAttachments` is floored at 1 — a cap of 0 or negative would strip the
37
37
  * primary, silently dropping the only attachment.
38
38
  */
39
+ /** Default attachments folded into one coalesced turn: a full Telegram album
40
+ * (media_group caps at 10). Floored at 1 so the only attachment is never
41
+ * stripped. Set channels.telegram.coalesce.max_attachments to override. */
42
+ export const DEFAULT_MAX_ATTACHMENTS = 10
43
+
44
+ export function resolveCoalesceMaxAttachments(configured: number | undefined): number {
45
+ return Math.max(1, configured ?? DEFAULT_MAX_ATTACHMENTS)
46
+ }
47
+
39
48
  export function splitCoalescedAttachments<T>(
40
49
  entries: T[],
41
50
  hasAttachment: (e: T) => boolean,
@@ -39,6 +39,7 @@ import {
39
39
  ToolFlightTracker,
40
40
  decideInterruptTiming,
41
41
  resolveInterruptMaxWaitMs,
42
+ resolveSafeBoundaryEnabled,
42
43
  } from './interrupt-defer.js'
43
44
  import {
44
45
  resolveStickerSendArgs,
@@ -56,10 +57,14 @@ import {
56
57
  } from '../telegraph.js'
57
58
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
58
59
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
59
- import { splitCoalescedAttachments, buildExtraAttachmentMeta } from './coalesce-attachments.js'
60
+ import {
61
+ splitCoalescedAttachments,
62
+ buildExtraAttachmentMeta,
63
+ resolveCoalesceMaxAttachments,
64
+ } from './coalesce-attachments.js'
60
65
  import { StatusReactionController } from '../status-reactions.js'
61
66
  import { DeferredDoneReactions } from '../reaction-defer.js'
62
- import { createWorkerActivityFeed } from '../worker-activity-feed.js'
67
+ import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
63
68
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
64
69
  import { appendActivityLabel } from '../tool-activity-summary.js'
65
70
  import { toolLabel } from '../tool-labels.js'
@@ -776,14 +781,15 @@ type Access = {
776
781
  parseMode?: 'html' | 'markdownv2' | 'text'
777
782
  disableLinkPreview?: boolean
778
783
  coalescingGapMs?: number
779
- /** A2: max media attachments folded into one coalesced turn. Default 1
780
- * (single-attachment behaviour). Projected from
784
+ /** A2: max media attachments folded into one coalesced turn. Default 10
785
+ * (a full Telegram album / forwarded burst arrives as one turn). Set 1 to
786
+ * restore single-attachment behaviour. Projected from
781
787
  * channels.telegram.coalesce.max_attachments by scaffold. */
782
788
  coalesceMaxAttachments?: number
783
- /** Problem B: when true, a `!` interrupt that lands mid-tool-call is
784
- * deferred until the in-flight tool finishes (bounded by
785
- * interruptMaxWaitMs) before SIGINT + resume. Default false (fire
786
- * synchronously). Projected from channels.telegram.interrupt.safe_boundary. */
789
+ /** Problem B: when true (the default), a `!` interrupt that lands
790
+ * mid-tool-call is deferred until the in-flight tool finishes (bounded by
791
+ * interruptMaxWaitMs) before SIGINT + resume. Set false to fire
792
+ * synchronously. Projected from channels.telegram.interrupt.safe_boundary. */
787
793
  interruptSafeBoundary?: boolean
788
794
  /** Upper bound (ms) to wait for a safe boundary before firing a deferred
789
795
  * interrupt anyway. Default 8000. Projected from
@@ -3137,13 +3143,13 @@ type CoalescePayload = {
3137
3143
 
3138
3144
  // Count of attachment-bearing entries currently buffered per coalesce key.
3139
3145
  // A new attachment for a key whose count has reached the per-agent cap
3140
- // (coalesce.max_attachments, default 1) bypasses coalescing (see
3146
+ // (coalesce.max_attachments, default 10) bypasses coalescing (see
3141
3147
  // handleInboundCoalesced) so no media is dropped past the cap. Cleared on
3142
3148
  // flush (below) and on the synchronous bypass path.
3143
3149
  const bufferedAttachmentKeys = new Map<string, number>()
3144
3150
 
3145
3151
  function coalesceMaxAttachments(): number {
3146
- return Math.max(1, loadAccess().coalesceMaxAttachments ?? 1)
3152
+ return resolveCoalesceMaxAttachments(loadAccess().coalesceMaxAttachments)
3147
3153
  }
3148
3154
 
3149
3155
  const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
@@ -8727,11 +8733,11 @@ async function handleInboundCoalesced(
8727
8733
  const maxAttachments = coalesceMaxAttachments()
8728
8734
 
8729
8735
  // Albums (media_group_id): coalesce only when the cap allows >1 attachment
8730
- // (A2). At the default cap of 1 each album part keeps its own turn exactly
8731
- // as before — the single-attachment merge can't carry sibling photos, so
8732
- // bypassing avoids dropping them. With a raised cap the parts share the
8733
- // coalesce key and fold into one multi-attachment turn (the cap-overflow
8734
- // bypass below catches parts past the cap).
8736
+ // (A2). At the default cap of 10 the parts share the coalesce key and fold
8737
+ // into one multi-attachment turn (the cap-overflow bypass below catches
8738
+ // parts past the cap). With the cap lowered to 1 each album part keeps its
8739
+ // own turn the single-attachment merge can't carry sibling photos, so
8740
+ // bypassing avoids dropping them.
8735
8741
  if (hasAttachment && ctx.message?.media_group_id != null && maxAttachments <= 1) {
8736
8742
  return handleInbound(ctx, text, downloadImage, attachment)
8737
8743
  }
@@ -8741,7 +8747,8 @@ async function handleInboundCoalesced(
8741
8747
 
8742
8748
  // An attachment past the per-agent cap would be dropped by the capped merge.
8743
8749
  // Bypass it to its own turn so no media is silently lost. At the default
8744
- // cap of 1 this fires on the SECOND attachment, preserving A1 behaviour.
8750
+ // cap of 10 this fires on the 11th attachment; with the cap lowered to 1 it
8751
+ // fires on the SECOND, preserving A1 behaviour.
8745
8752
  if (hasAttachment) {
8746
8753
  const probeKey = inboundCoalesceKey(
8747
8754
  String(ctx.chat!.id),
@@ -8785,9 +8792,9 @@ async function handleInboundCoalesced(
8785
8792
  // Coalescing disabled (window <= 0): flush immediately, preserving any
8786
8793
  // media this message carried.
8787
8794
  if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
8788
- // Count the open window's attachments so a third+ (or second, at the
8789
- // default cap) bypasses rather than overflows the capped merge (cleared
8790
- // in onFlush).
8795
+ // Count the open window's attachments so any part past the cap (the 11th
8796
+ // at the default cap of 10, or the second when lowered to 1) bypasses
8797
+ // rather than overflows the capped merge (cleared in onFlush).
8791
8798
  if (hasAttachment) bufferedAttachmentKeys.set(key, (bufferedAttachmentKeys.get(key) ?? 0) + 1)
8792
8799
  }
8793
8800
 
@@ -8998,7 +9005,7 @@ async function handleInbound(
8998
9005
  deferInterrupt =
8999
9006
  !interrupt.emptyBody &&
9000
9007
  decideInterruptTiming({
9001
- safeBoundaryEnabled: access.interruptSafeBoundary === true,
9008
+ safeBoundaryEnabled: resolveSafeBoundaryEnabled(access.interruptSafeBoundary),
9002
9009
  midToolCall: toolFlightTracker.isMidToolCall(),
9003
9010
  }) === 'defer'
9004
9011
  process.stderr.write(
@@ -17565,10 +17572,11 @@ void (async () => {
17565
17572
  // and edits it in place as work happens (current tool + elapsed),
17566
17573
  // finalizing on completion — the same "live, growing message"
17567
17574
  // shape the main agent's answer uses, NOT card chrome (the pinned
17568
- // card was deleted in #1126). Flag-gated; when ON it also
17575
+ // card was deleted in #1126). On by default (set
17576
+ // SWITCHROOM_WORKER_ACTIVITY_FEED=0 to disable); when ON it also
17569
17577
  // supersedes the coarse 5-min bucket relay below to avoid
17570
17578
  // double-surfacing the same progress beat.
17571
- const workerFeedEnabled = process.env.SWITCHROOM_WORKER_ACTIVITY_FEED === '1'
17579
+ const workerFeedEnabled = isWorkerActivityFeedEnabled(process.env.SWITCHROOM_WORKER_ACTIVITY_FEED)
17572
17580
  const workerActivityFeed = createWorkerActivityFeed({
17573
17581
  bot: {
17574
17582
  sendMessage: async (cid, text, sendOpts) => {
@@ -98,3 +98,9 @@ export function resolveInterruptMaxWaitMs(configured: number | undefined): numbe
98
98
  if (typeof configured === 'number' && configured > 0) return configured
99
99
  return DEFAULT_INTERRUPT_MAX_WAIT_MS
100
100
  }
101
+
102
+ /** safe_boundary defaults ON: a `!` mid-tool-call is deferred to a clean
103
+ * boundary unless the operator explicitly sets it false. */
104
+ export function resolveSafeBoundaryEnabled(configured: boolean | undefined): boolean {
105
+ return configured !== false
106
+ }
@@ -2,22 +2,40 @@
2
2
  * Unit tests for the A2 multi-attachment helpers
3
3
  * (telegram-plugin/gateway/coalesce-attachments.ts).
4
4
  *
5
- * These pin the two pure pieces of the multi-attachment fold-in that live
5
+ * These pin the pure pieces of the multi-attachment fold-in that live
6
6
  * outside gateway.ts so they can be exercised without loadAccess()/IPC:
7
- * 1. splitCoalescedAttachmentsprimary + capped extras, arrival order.
8
- * 2. buildExtraAttachmentMetanumbered meta fields starting at _2.
7
+ * 1. resolveCoalesceMaxAttachmentsthe runtime cap default (10).
8
+ * 2. splitCoalescedAttachmentsprimary + capped extras, arrival order.
9
+ * 3. buildExtraAttachmentMeta — numbered meta fields starting at _2.
9
10
  *
10
- * The default cap (1) MUST reproduce the historical single-attachment shape:
11
- * primary only, no extras, no numbered meta.
11
+ * A cap of 1 reproduces the historical single-attachment shape: primary
12
+ * only, no extras, no numbered meta.
12
13
  */
13
14
 
14
15
  import { describe, expect, it } from 'vitest'
15
16
  import {
16
17
  splitCoalescedAttachments,
17
18
  buildExtraAttachmentMeta,
19
+ resolveCoalesceMaxAttachments,
20
+ DEFAULT_MAX_ATTACHMENTS,
18
21
  type ResolvedExtraAttachment,
19
22
  } from '../gateway/coalesce-attachments.js'
20
23
 
24
+ describe('resolveCoalesceMaxAttachments (default 10 = full album)', () => {
25
+ it('defaults to 10 when unset', () => {
26
+ expect(resolveCoalesceMaxAttachments(undefined)).toBe(10)
27
+ expect(DEFAULT_MAX_ATTACHMENTS).toBe(10)
28
+ })
29
+ it('honours an explicit operator cap', () => {
30
+ expect(resolveCoalesceMaxAttachments(1)).toBe(1)
31
+ expect(resolveCoalesceMaxAttachments(25)).toBe(25)
32
+ })
33
+ it('floors a 0 / negative cap at 1 (never strips the only attachment)', () => {
34
+ expect(resolveCoalesceMaxAttachments(0)).toBe(1)
35
+ expect(resolveCoalesceMaxAttachments(-5)).toBe(1)
36
+ })
37
+ })
38
+
21
39
  interface Entry {
22
40
  text: string
23
41
  att?: string
@@ -26,7 +44,7 @@ interface Entry {
26
44
  const has = (e: Entry): boolean => e.att != null
27
45
 
28
46
  describe('splitCoalescedAttachments', () => {
29
- it('default cap 1: keeps only the first attachment as primary, no extras', () => {
47
+ it('cap 1: keeps only the first attachment as primary, no extras', () => {
30
48
  const entries: Entry[] = [
31
49
  { text: 'a', att: 'photo-1' },
32
50
  { text: 'b', att: 'photo-2' },
@@ -15,6 +15,7 @@ import {
15
15
  ToolFlightTracker,
16
16
  decideInterruptTiming,
17
17
  resolveInterruptMaxWaitMs,
18
+ resolveSafeBoundaryEnabled,
18
19
  DEFAULT_INTERRUPT_MAX_WAIT_MS,
19
20
  } from '../gateway/interrupt-defer.js'
20
21
 
@@ -119,6 +120,18 @@ describe('decideInterruptTiming', () => {
119
120
  })
120
121
  })
121
122
 
123
+ describe('resolveSafeBoundaryEnabled (default ON)', () => {
124
+ it('defaults to true when unset', () => {
125
+ expect(resolveSafeBoundaryEnabled(undefined)).toBe(true)
126
+ })
127
+ it('stays true when explicitly true', () => {
128
+ expect(resolveSafeBoundaryEnabled(true)).toBe(true)
129
+ })
130
+ it('only an explicit false opts out', () => {
131
+ expect(resolveSafeBoundaryEnabled(false)).toBe(false)
132
+ })
133
+ })
134
+
122
135
  describe('resolveInterruptMaxWaitMs', () => {
123
136
  it('uses the configured value when positive', () => {
124
137
  expect(resolveInterruptMaxWaitMs(3000)).toBe(3000)
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Structural pin for the permission-card resume beat.
3
+ *
4
+ * What broke (and the bug this guards against): when the operator
5
+ * answers a permission card, the suspended `claude` turn un-parks and
6
+ * resumes the SAME turn — the gateway must flip the awaiting glyph
7
+ * (🙏) back to a working glyph so the operator sees progress instead
8
+ * of a stuck card. That flip is `resumeReactionAfterVerdict()`.
9
+ *
10
+ * The verdict can arrive down several independent paths (button tap,
11
+ * always-allow, `/allow`·`/deny`, TTL auto-deny, free-text `y <id>`/
12
+ * `no <id>` reply, …). Every one of them calls
13
+ * `dispatchPermissionVerdict(...)` to un-park the turn — but the resume
14
+ * glyph flip is a *separate* call right next to it. The free-text-reply
15
+ * path shipped the dispatch WITHOUT the resume (fixed in v0.14.19), so
16
+ * answering via a text reply left the card frozen on 🙏 even though the
17
+ * turn was running. The controller-level behaviour is covered by
18
+ * `status-reactions.test.ts` ("setAwaiting" + watchdog re-arm); mtcute
19
+ * UAT cannot observe reactions at all, so this static pin is the only
20
+ * thing that catches a verdict path forgetting the resume.
21
+ *
22
+ * This guard fails loudly if any `dispatchPermissionVerdict(...)`
23
+ * callsite is not paired with a `resumeReactionAfterVerdict()` within a
24
+ * few lines — i.e. a new (or refactored) verdict path drops the resume.
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest'
28
+ import { readFileSync } from 'node:fs'
29
+ import { fileURLToPath } from 'node:url'
30
+ import { dirname, resolve } from 'node:path'
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url))
33
+ const GATEWAY_SRC = readFileSync(
34
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
35
+ 'utf8',
36
+ )
37
+
38
+ const LINES = GATEWAY_SRC.split('\n')
39
+
40
+ // A `dispatchPermissionVerdict(` occurrence is a CALLSITE unless it's the
41
+ // function definition itself.
42
+ const isDefinition = (line: string) =>
43
+ /\bfunction\s+dispatchPermissionVerdict\b/.test(line)
44
+
45
+ const dispatchCallsites = LINES.flatMap((line, i) =>
46
+ /\bdispatchPermissionVerdict\s*\(/.test(line) && !isDefinition(line)
47
+ ? [i]
48
+ : [],
49
+ )
50
+
51
+ // How far below the dispatch the resume call is allowed to live. The
52
+ // widest real gap today is ~9 lines (the slash-command path); 15 gives
53
+ // refactor headroom without letting an unrelated resume "cover" a
54
+ // dispatch from a different block.
55
+ const RESUME_WINDOW = 15
56
+
57
+ describe('permission verdict → resume reaction wiring', () => {
58
+ it('there is at least one verdict-dispatch path to guard', () => {
59
+ expect(dispatchCallsites.length).toBeGreaterThan(0)
60
+ })
61
+
62
+ it('every dispatchPermissionVerdict() callsite flips the awaiting glyph back via resumeReactionAfterVerdict()', () => {
63
+ const unpaired: number[] = []
64
+ for (const idx of dispatchCallsites) {
65
+ const window = LINES.slice(idx, idx + RESUME_WINDOW + 1).join('\n')
66
+ if (!/\bresumeReactionAfterVerdict\s*\(\s*\)/.test(window)) {
67
+ // 1-based line number for a human-readable failure.
68
+ unpaired.push(idx + 1)
69
+ }
70
+ }
71
+ expect(
72
+ unpaired,
73
+ `dispatchPermissionVerdict() at gateway.ts line(s) ` +
74
+ `${unpaired.join(', ')} has no resumeReactionAfterVerdict() within ` +
75
+ `${RESUME_WINDOW} lines — that verdict path leaves the permission ` +
76
+ `card stuck on 🙏 after the operator answers. Add the resume call ` +
77
+ `(see the sibling paths and v0.14.19 / the free-text-reply fix).`,
78
+ ).toEqual([])
79
+ })
80
+
81
+ it('the resume helper still exists (the pairing is meaningless if it was deleted)', () => {
82
+ expect(/function\s+resumeReactionAfterVerdict\s*\(/.test(GATEWAY_SRC)).toBe(
83
+ true,
84
+ )
85
+ })
86
+ })
@@ -2,10 +2,24 @@ import { describe, it, expect } from 'vitest'
2
2
  import {
3
3
  renderWorkerActivity,
4
4
  createWorkerActivityFeed,
5
+ isWorkerActivityFeedEnabled,
5
6
  type WorkerActivityView,
6
7
  type BotApiForWorkerFeed,
7
8
  } from '../worker-activity-feed.js'
8
9
 
10
+ describe('isWorkerActivityFeedEnabled (default ON)', () => {
11
+ it('defaults to true when the env var is unset', () => {
12
+ expect(isWorkerActivityFeedEnabled(undefined)).toBe(true)
13
+ })
14
+ it('stays on for any value other than "0"', () => {
15
+ expect(isWorkerActivityFeedEnabled('1')).toBe(true)
16
+ expect(isWorkerActivityFeedEnabled('')).toBe(true)
17
+ })
18
+ it('only "0" disables it', () => {
19
+ expect(isWorkerActivityFeedEnabled('0')).toBe(false)
20
+ })
21
+ })
22
+
9
23
  function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
10
24
  return {
11
25
  description: 'research competitors',
@@ -11,6 +11,59 @@
11
11
 
12
12
  import type { Driver, ObservedMessage, ObservedReaction } from "./driver.js";
13
13
 
14
+ /**
15
+ * Canonical shape of a worker-activity-feed message (#2000) as rendered
16
+ * in Telegram: a running header `🔧 Worker · …` that edits in place and
17
+ * finalizes to `✅ Worker done · …` / `⚠️ Worker failed · …`. The feed is
18
+ * default-on fleet-wide as of v0.14.19, so background sub-agent activity
19
+ * now surfaces as its own bot message in any chat — including DMs whose
20
+ * scenario only cares about the agent's conversational reply.
21
+ *
22
+ * Single source of truth; the worker-feed scenario asserts against this,
23
+ * and recall/reply scenarios exclude it via {@link isWorkerFeedMessage}.
24
+ */
25
+ export const WORKER_FEED_RE = /🔧\s*Worker|✅\s*Worker done|⚠️\s*Worker failed|Worker (?:done|failed)/i;
26
+
27
+ /**
28
+ * True when `m` is a worker-activity-feed message rather than the agent's
29
+ * own reply. Use it to skip feed noise when matching for a turn's actual
30
+ * answer — without it, an `expectMessage(/\S/)` can latch onto the feed's
31
+ * first paint and miss (or mis-time) the real reply. See #2000 / the
32
+ * memory-survives-restart recall scenario.
33
+ */
34
+ export function isWorkerFeedMessage(m: ObservedMessage): boolean {
35
+ return WORKER_FEED_RE.test(m.text);
36
+ }
37
+
38
+ /**
39
+ * A single tool-activity-feed line as rendered by
40
+ * `renderActivityFeed` (telegram-plugin/tool-activity-summary.ts): the
41
+ * in-progress step is `→ <label>`, finished steps are `✓ <label>`, and a
42
+ * long turn gets a `✓ +N earlier…` header. Telegram strips the bold/italic
43
+ * wrapping, so the observed text is just the marker glyph + label.
44
+ */
45
+ const ACTIVITY_FEED_LINE_RE = /^[→✓]\s/u;
46
+
47
+ /**
48
+ * True when `m` is the live tool-activity feed (the one-message list of
49
+ * "what the agent is doing this turn") rather than the agent's reply. A
50
+ * message qualifies only when EVERY non-empty line is an activity line —
51
+ * so a real reply that merely contains an arrow is never misclassified.
52
+ *
53
+ * Recall/reply scenarios must skip this in addition to
54
+ * {@link isWorkerFeedMessage}: on a turn that uses tools, the feed paints
55
+ * `→ Finding the right tool` as its own bot message before the real answer
56
+ * lands, and an `expectMessage(/\S/)` would otherwise latch onto it.
57
+ */
58
+ export function isActivityFeedMessage(m: ObservedMessage): boolean {
59
+ const lines = m.text
60
+ .split("\n")
61
+ .map((l) => l.trim())
62
+ .filter((l) => l.length > 0);
63
+ if (lines.length === 0) return false;
64
+ return lines.every((l) => ACTIVITY_FEED_LINE_RE.test(l));
65
+ }
66
+
14
67
  export interface PollOptions {
15
68
  /** Hard deadline; the predicate must resolve truthy before this. */
16
69
  timeout: number;
@@ -646,6 +646,34 @@ export class Driver {
646
646
  return { messageId: sent.id };
647
647
  }
648
648
 
649
+ /**
650
+ * Send a photo album (Telegram media_group) — multiple photos posted as
651
+ * one group, the way a forwarded album or a multi-image paste arrives.
652
+ * Exercises the gateway's A2 multi-attachment coalescing: with
653
+ * coalesce.max_attachments default 10, the whole album folds into ONE
654
+ * Claude turn (the agent sees image_path, image_path_2, …). The optional
655
+ * caption rides on the first item, matching Telegram client behaviour.
656
+ * Returns every sent message id (one per album item).
657
+ */
658
+ async sendAlbum(
659
+ chatId: number,
660
+ photoPaths: string[],
661
+ caption?: string,
662
+ opts?: SendTextOptions,
663
+ ): Promise<{ messageIds: number[] }> {
664
+ const c = this.requireClient();
665
+ const replyTo = opts?.replyTo ?? opts?.messageThreadId;
666
+ const medias = photoPaths.map((p, i) =>
667
+ InputMedia.photo(p, i === 0 && caption ? { caption } : undefined),
668
+ );
669
+ const sent = await c.sendMediaGroup(
670
+ chatId,
671
+ medias,
672
+ replyTo ? { replyTo } : undefined,
673
+ );
674
+ return { messageIds: sent.map((m) => m.id) };
675
+ }
676
+
649
677
  /**
650
678
  * Send or remove an emoji reaction on a target message. Used by the
651
679
  * UAT reaction-trigger scenario (#1074) to exercise the gateway's
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ isActivityFeedMessage,
4
+ isWorkerFeedMessage,
5
+ WORKER_FEED_RE,
6
+ } from "./assertions.js";
7
+
8
+ // Pins the worker-activity-feed detector (#2000) used by recall/reply
9
+ // scenarios to skip feed noise. The live UAT it guards can't run in CI
10
+ // (needs sudo + a real Telegram session), so this is the CI-verifiable
11
+ // floor for the matcher's behavior.
12
+ const feed = (text: string) => ({ text }) as Parameters<typeof isWorkerFeedMessage>[0];
13
+
14
+ describe("isWorkerFeedMessage", () => {
15
+ it("matches the running feed header", () => {
16
+ expect(isWorkerFeedMessage(feed("🔧 Worker · crawling changelog · 0:12"))).toBe(true);
17
+ });
18
+
19
+ it("matches the terminal done/failed recaps", () => {
20
+ expect(isWorkerFeedMessage(feed("✅ Worker done · 10 tools · 1:03"))).toBe(true);
21
+ expect(isWorkerFeedMessage(feed("⚠️ Worker failed · 3 tools"))).toBe(true);
22
+ });
23
+
24
+ it("matches a done/failed header even without the leading emoji", () => {
25
+ expect(isWorkerFeedMessage(feed("Worker done · 2 tools"))).toBe(true);
26
+ expect(isWorkerFeedMessage(feed("Worker failed mid-step"))).toBe(true);
27
+ });
28
+
29
+ it("does NOT match an ordinary agent reply", () => {
30
+ expect(isWorkerFeedMessage(feed("on it, pulling the logs now"))).toBe(false);
31
+ expect(
32
+ isWorkerFeedMessage(feed("SWITCHROOM_UAT_MEM_DEADBEEFCAFE1234")),
33
+ ).toBe(false);
34
+ });
35
+
36
+ it("does NOT match a reply that merely mentions the word worker", () => {
37
+ expect(
38
+ isWorkerFeedMessage(feed("I'll dispatch a worker to handle the crawl.")),
39
+ ).toBe(false);
40
+ });
41
+
42
+ it("exposes the regex for scenarios that assert on the feed directly", () => {
43
+ expect(WORKER_FEED_RE.test("🔧 Worker · x")).toBe(true);
44
+ });
45
+ });
46
+
47
+ describe("isActivityFeedMessage", () => {
48
+ it("matches the in-progress step line", () => {
49
+ expect(isActivityFeedMessage(feed("→ Finding the right tool"))).toBe(true);
50
+ });
51
+
52
+ it("matches a multi-line feed (done steps + in-progress)", () => {
53
+ expect(
54
+ isActivityFeedMessage(feed("✓ Reading CLAUDE.md\n→ Searching memory")),
55
+ ).toBe(true);
56
+ });
57
+
58
+ it("matches the +N earlier header", () => {
59
+ expect(
60
+ isActivityFeedMessage(feed("✓ +3 earlier…\n✓ Reading CLAUDE.md\n→ Searching memory")),
61
+ ).toBe(true);
62
+ });
63
+
64
+ it("does NOT match an ordinary agent reply", () => {
65
+ expect(isActivityFeedMessage(feed("on it, pulling the logs now"))).toBe(false);
66
+ expect(
67
+ isActivityFeedMessage(feed("SWITCHROOM_UAT_MEM_DEADBEEFCAFE1234")),
68
+ ).toBe(false);
69
+ });
70
+
71
+ it("does NOT match a reply that merely contains an arrow mid-text", () => {
72
+ expect(
73
+ isActivityFeedMessage(feed("The flow is request → response → render.")),
74
+ ).toBe(false);
75
+ });
76
+
77
+ it("does NOT match an empty message", () => {
78
+ expect(isActivityFeedMessage(feed(" "))).toBe(false);
79
+ });
80
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Album-coalescing scenario — driver sends a 3-photo Telegram album
3
+ * (media_group) in one shot; the gateway's A2 multi-attachment
4
+ * coalescing (coalesce.max_attachments, default 10 since v0.14.21)
5
+ * MUST fold all three into a SINGLE Claude turn, so the agent sees
6
+ * image_path + image_path_2 + image_path_3 together and can report a
7
+ * count of 3.
8
+ *
9
+ * Regression gate for the default-on flip (#2021): before max_attachments
10
+ * defaulted to 10, an album bypassed coalescing (each part its own turn),
11
+ * so the agent would only ever see ONE image per turn and answer "1".
12
+ * A reply of "3" proves the album coalesced.
13
+ *
14
+ * Part of: https://github.com/switchroom/switchroom/issues/865
15
+ *
16
+ * ## How the signal is read robustly
17
+ *
18
+ * The agent's answer is a bare count, which would collide with incidental
19
+ * digits the chat is now full of by default: the pinned progress card's
20
+ * timer (`00:03`) and — since the worker feed went default-on fleet-wide
21
+ * (#2009 / v0.14.19) — worker-feed lines like `🔧 Worker · … · 0:12`.
22
+ * A "first bot message containing a digit" matcher would latch onto one of
23
+ * those and false-fail even when coalescing is healthy (see the
24
+ * memory-survives-restart matcher-flake note + `isWorkerFeedMessage`).
25
+ *
26
+ * Two defences, mirroring the sibling `jtbd-forwarded-burst-dm` gate:
27
+ * 1. Anchor the answer on a distinctive token — the agent is told to
28
+ * reply `IMAGECOUNT=<n>`. "IMAGECOUNT" never appears in a card or a
29
+ * worker-feed line, so the matcher cannot collide with their digits.
30
+ * 2. Drain observed messages into a per-id map (collapsing streamed
31
+ * edits to latest text), skip our own sends + worker-feed noise, and
32
+ * poll until the ANSWER token appears — rather than returning the
33
+ * first message that happens to match.
34
+ *
35
+ * - Coalesced → one turn sees 3 images → `IMAGECOUNT=3`.
36
+ * - Non-coalesced → the turn carrying the caption/question sees only its
37
+ * own (first) image → `IMAGECOUNT=1`. Surfaced as an explicit failure.
38
+ *
39
+ * Fixtures: three tiny solid-colour JPEGs under fixtures/album/, committed
40
+ * so the gate runs without a generation step. (Regenerate with
41
+ * `ffmpeg -f lavfi -i color=c=red:s=320x240 -frames:v 1 red.jpg`.)
42
+ */
43
+
44
+ import path from "node:path";
45
+ import { existsSync } from "node:fs";
46
+ import { describe, expect, it } from "vitest";
47
+ import { spinUp } from "../harness.js";
48
+ import { pollUntil, isWorkerFeedMessage } from "../assertions.js";
49
+ import type { ObservedMessage } from "../driver.js";
50
+
51
+ const AGENT = "test-harness";
52
+
53
+ const FIXTURE_DIR = path.resolve(__dirname, "..", "fixtures", "album");
54
+ const PHOTOS = ["red.jpg", "green.jpg", "blue.jpg"].map((f) =>
55
+ path.join(FIXTURE_DIR, f),
56
+ );
57
+
58
+ const CAPTION =
59
+ "I just sent you a photo album in a single message. Count the separate " +
60
+ "image files you received in THIS ONE incoming message and reply with " +
61
+ "ONLY the token IMAGECOUNT=<n> (e.g. IMAGECOUNT=3). Nothing else.";
62
+
63
+ // Warm TTFO on test-harness is ~7s; an album adds the (sub-second)
64
+ // coalesce window plus the model looking at three images.
65
+ const ANSWER_TIMEOUT_MS = 90_000;
66
+
67
+ // Pull the count out of an `IMAGECOUNT=<n>` token, tolerating "= : whitespace".
68
+ function imageCountIn(text: string): number | undefined {
69
+ const m = text.match(/IMAGECOUNT\s*[:=]?\s*(\d+)/i);
70
+ return m ? Number.parseInt(m[1], 10) : undefined;
71
+ }
72
+
73
+ describe("uat: album-coalescing DM round-trip", () => {
74
+ it(
75
+ "a 3-photo album folds into ONE turn — agent reports seeing 3 images",
76
+ async () => {
77
+ for (const p of PHOTOS) {
78
+ if (!existsSync(p)) {
79
+ throw new Error(
80
+ `album fixture missing at ${p} — see scenario header to regenerate`,
81
+ );
82
+ }
83
+ }
84
+ const sc = await spinUp({ agent: AGENT });
85
+ try {
86
+ // Observe BEFORE sending — observeMessages only sees live updates.
87
+ // Drain into a per-id map so streamed edits collapse to latest text;
88
+ // skip our own sends and worker-feed noise so neither can satisfy
89
+ // the count matcher.
90
+ const latestById = new Map<number, ObservedMessage>();
91
+ const stream = sc.driver.observeMessages(sc.botUserId);
92
+ const consume = (async () => {
93
+ for await (const m of stream) {
94
+ if (m.senderUserId === sc.driverUserId) continue;
95
+ if (isWorkerFeedMessage(m)) continue;
96
+ latestById.set(m.messageId, m);
97
+ }
98
+ })();
99
+
100
+ await sc.driver.sendAlbum(sc.botUserId, PHOTOS, CAPTION);
101
+
102
+ // Poll until a bot message carries an IMAGECOUNT token.
103
+ const answer = await pollUntil(
104
+ () => {
105
+ for (const m of latestById.values()) {
106
+ if (imageCountIn(m.text) !== undefined) return m;
107
+ }
108
+ return undefined;
109
+ },
110
+ { timeout: ANSWER_TIMEOUT_MS, interval: 500 },
111
+ ).catch(() => undefined);
112
+
113
+ await stream[Symbol.asyncIterator]().return?.(undefined as never);
114
+ await consume;
115
+
116
+ if (!answer) {
117
+ const seen = [...latestById.values()]
118
+ .map((m) => `#${m.messageId}=${JSON.stringify(m.text.slice(0, 60))}`)
119
+ .join(" ");
120
+ throw new Error(
121
+ `[album-coalescing] No bot reply carried an IMAGECOUNT token ` +
122
+ `within ${ANSWER_TIMEOUT_MS}ms. Bot messages seen: ${seen || "(none)"}.`,
123
+ );
124
+ }
125
+
126
+ const count = imageCountIn(answer.text);
127
+ // Coalesced => 3. A non-coalescing gateway answers 1 (the question
128
+ // rides photo #1; the other two parts spill to their own turns).
129
+ expect(count).toBe(3);
130
+ } finally {
131
+ await sc.tearDown();
132
+ }
133
+ },
134
+ ANSWER_TIMEOUT_MS + 30_000,
135
+ );
136
+ });
@@ -53,9 +53,24 @@ import { describe, it, expect, beforeAll } from "vitest";
53
53
  import { execSync } from "node:child_process";
54
54
  import { randomBytes } from "node:crypto";
55
55
  import { spinUp } from "../harness.js";
56
+ import { isActivityFeedMessage, isWorkerFeedMessage } from "../assertions.js";
57
+ import type { ObservedMessage } from "../driver.js";
56
58
 
57
59
  const AGENT = "test-harness";
58
60
 
61
+ // Two classes of bot message are NOT the agent's reply and must be
62
+ // skipped, or a bare `/\S/` matcher latches onto them and the
63
+ // verbatim-token check fails against noise instead of catching a real
64
+ // memory miss:
65
+ // 1. The worker-activity feed (#2000, default-on since v0.14.19) — a
66
+ // stray background sub-agent posts `🔧 Worker · …` into this DM.
67
+ // 2. The tool-activity feed — on a turn that uses tools (memory recall
68
+ // does), `→ Finding the right tool` paints as its own message before
69
+ // the real answer lands.
70
+ // Match the first non-empty bot reply that is neither.
71
+ const isReply = (m: ObservedMessage): boolean =>
72
+ /\S/.test(m.text) && !isWorkerFeedMessage(m) && !isActivityFeedMessage(m);
73
+
59
74
  const RESTART_BUDGET_MS = 90_000;
60
75
  const CAPTURE_REPLY_BUDGET_MS = 60_000;
61
76
  const RECALL_REPLY_BUDGET_MS = 120_000;
@@ -139,7 +154,7 @@ const sudoOk = canShellSudo();
139
154
  `(This is a memory-survival UAT — store it via hindsight.)`,
140
155
  );
141
156
 
142
- const captureReply = await sc1.expectMessage(/\S/, {
157
+ const captureReply = await sc1.expectMessage(isReply, {
143
158
  from: "bot",
144
159
  timeout: CAPTURE_REPLY_BUDGET_MS,
145
160
  });
@@ -180,7 +195,7 @@ const sudoOk = canShellSudo();
180
195
  `Reply with the token only, no extra text.`,
181
196
  );
182
197
 
183
- const recallReply = await sc2.expectMessage(/\S/, {
198
+ const recallReply = await sc2.expectMessage(isReply, {
184
199
  from: "bot",
185
200
  timeout: RECALL_REPLY_BUDGET_MS + 5_000,
186
201
  });
@@ -25,15 +25,21 @@
25
25
  * - 429 cooldown + message_id drift resilience (re-post on stale edit),
26
26
  * - a forced terminal edit on `finish` regardless of throttle.
27
27
  *
28
- * The feed is gated to BACKGROUND workers and lives behind the
29
- * `SWITCHROOM_WORKER_ACTIVITY_FEED` flag — see the gateway wiring. The
30
- * watcher already drives the cues (it polls the worker jsonl directly,
31
- * so it keeps firing after the parent turn ends), which is why the feed
32
- * is fed from watcher callbacks rather than the bridge event stream.
28
+ * The feed is gated to BACKGROUND workers and is ON by default; set
29
+ * `SWITCHROOM_WORKER_ACTIVITY_FEED=0` to disable it — see the gateway
30
+ * wiring. The watcher already drives the cues (it polls the worker jsonl
31
+ * directly, so it keeps firing after the parent turn ends), which is why
32
+ * the feed is fed from watcher callbacks rather than the bridge event stream.
33
33
  */
34
34
 
35
35
  import { escapeHtml, formatDuration, truncate } from './card-format.js'
36
36
 
37
+ /** Worker-activity feed is ON by default; an operator opts out with
38
+ * SWITCHROOM_WORKER_ACTIVITY_FEED=0. */
39
+ export function isWorkerActivityFeedEnabled(envVal: string | undefined): boolean {
40
+ return envVal !== '0'
41
+ }
42
+
37
43
  export type WorkerActivityState = 'running' | 'done' | 'failed'
38
44
 
39
45
  /** The render-relevant snapshot of a worker at one instant. */