switchroom 0.15.7 → 0.15.9

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.
@@ -327,6 +327,18 @@ function mergeAgentConfig(defaultsIn, agentIn) {
327
327
  }
328
328
  merged.reactions = combined;
329
329
  }
330
+ const dReactionDispatch = defaults.reaction_dispatch;
331
+ const mReactionDispatch = merged.reaction_dispatch;
332
+ if (dReactionDispatch || mReactionDispatch) {
333
+ const base = dReactionDispatch ?? {};
334
+ const override = mReactionDispatch ?? {};
335
+ const combined = { ...base };
336
+ for (const [k, v] of Object.entries(override)) {
337
+ if (v !== undefined)
338
+ combined[k] = v;
339
+ }
340
+ merged.reaction_dispatch = combined;
341
+ }
330
342
  if (defaults.resources || merged.resources) {
331
343
  const d = defaults.resources ?? {};
332
344
  const a = merged.resources ?? {};
@@ -11277,7 +11289,7 @@ var init_zod = __esm(() => {
11277
11289
  });
11278
11290
 
11279
11291
  // src/config/schema.ts
11280
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11292
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
11281
11293
  var init_schema = __esm(() => {
11282
11294
  init_zod();
11283
11295
  CodeRepoEntrySchema = exports_external.object({
@@ -11553,6 +11565,10 @@ var init_schema = __esm(() => {
11553
11565
  per_hour_cap: exports_external.number().int().nonnegative().optional().describe("Max reaction-triggered synthetic turns per chat per rolling hour. " + "Refusals are stderr-logged but not surfaced to the agent. " + "Default 10. Set to 0 to disable triggering via the cap path."),
11554
11566
  group_admin_only: exports_external.boolean().optional().describe("In groups/supergroups (negative chat_id), only trigger a synthetic " + "turn when the reacter is a chat admin (creator or administrator). " + "Failing the lookup is treated as non-admin (fail-closed). " + "DMs are never affected by this flag — the reacter IS the user. " + "Default true.")
11555
11567
  }).optional();
11568
+ ReactionDispatchSchema = exports_external.object({
11569
+ enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false — " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
11570
+ emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) — a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
11571
+ }).optional();
11556
11572
  ReleaseBlock = exports_external.object({
11557
11573
  channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
11558
11574
  pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
@@ -11588,6 +11604,7 @@ var init_schema = __esm(() => {
11588
11604
  schedule: exports_external.array(ScheduleEntrySchema).optional(),
11589
11605
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional().describe("Operator-granted STANDING vault keys this agent may read via the " + "broker — independent of any cron or MCP server. Use when an agent " + "needs a credential both interactively and in its own (agent-managed) " + "schedules, so the grant lives with the agent rather than welded to a " + "specific cron's `secrets[]`. OPERATOR-SET ONLY: agents cannot edit " + "switchroom.yaml or self-grant (reference/vision.md outcome 2 — 'you " + "hold the leash; only your tap grants it'). Exact key names. Cascades " + "UNION across defaults -> profile -> agent (see docs/configuration.md)."),
11590
11606
  reactions: ReactionsSchema,
11607
+ reaction_dispatch: ReactionDispatchSchema,
11591
11608
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only").optional(),
11592
11609
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
11593
11610
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Omit to use Claude's default (acceptEdits for switchroom agents). " + "Warning: bypassPermissions and dontAsk skip all safety checks — use only in trusted sandboxes."),
@@ -11657,6 +11674,7 @@ var init_schema = __esm(() => {
11657
11674
  schedule: exports_external.array(ScheduleEntrySchema).default([]),
11658
11675
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional(),
11659
11676
  reactions: ReactionsSchema,
11677
+ reaction_dispatch: ReactionDispatchSchema,
11660
11678
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only (no spaces or shell specials)").optional().describe("Claude model override (e.g., 'claude-sonnet-4-6')"),
11661
11679
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "Per-agent override wins over defaults.thinking_effort. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
11662
11680
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Per-agent override wins over defaults.permission_mode. " + "Warning: bypassPermissions and dontAsk skip all safety checks — use only in trusted sandboxes."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.7",
3
+ "version": "0.15.9",
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": {
@@ -28,14 +28,33 @@ tools is to let you do the edit yourself.
28
28
 
29
29
  ### Tools
30
30
 
31
- - **`schedule_add(cron_expr, prompt, name?, secrets?)`** — append a new schedule
32
- entry. The `prompt` is what *you* (the agent) will receive when the cron
33
- fires; phrase it from your future-self's perspective (e.g.
34
- `"Time for the daily digest pull yesterday's GitHub activity and DM the
35
- summary to chat 12345"`, not `"please send the digest"`). Optional
31
+ - **`schedule_add(cron_expr, prompt, name?, secrets?, model?, context?)`** —
32
+ append a new schedule entry. Takes effect within **~30s** (the scheduler
33
+ hot-reloads no restart needed). The `prompt` is what *you* (the agent) will
34
+ receive when the cron fires; phrase it from your future-self's perspective
35
+ (e.g. `"Time for the daily digest — pull yesterday's GitHub activity and DM
36
+ the summary to chat 12345"`, not `"please send the digest"`). Optional
36
37
  `name` is a stable slug for `schedule_remove`; if omitted, a 12-hex hash
37
38
  derived from the entry content is assigned.
38
39
 
40
+ **Mind the cost — pick the cheapest tier that does the job:**
41
+ - *Default (no `model`)* — the fire runs as a **full turn in your live
42
+ session**: your model, your whole context + memory. Right for work that
43
+ genuinely needs *you* (your persona, your conversation history). Costly for
44
+ routine checks — every fire pays your full context.
45
+ - *`model: "sonnet"`* (or `context: "fresh"`) — routes the fire to a **cheap,
46
+ minimal-context cron session** (Tier 1): a fresh Sonnet with just the
47
+ prompt, no heavy context. Use for light, self-contained recurring work
48
+ (summarise a feed, format a digest) where you don't need your memory. Much
49
+ cheaper per fire. *(Honoured only when the operator has enabled cheap-cron;
50
+ otherwise it still runs as a normal turn — never silently dropped.)*
51
+ - *"Only act when X changes"* — don't poll with a frequent prompt cron (every
52
+ fire is a wasted turn when nothing changed). Ask the **operator** to set up
53
+ a **poll** (model-free check, e.g. a webpage/API via `kind: poll`) or, for
54
+ reaction-triggered work, **`reaction_dispatch`** (an emoji reaction wakes
55
+ you instantly — zero polling). These need an operator config commit
56
+ (egress/identity gates), so request them rather than authoring them yourself.
57
+
39
58
  - **`schedule_remove(name | cron_hash)`** — delete by `name` (the slug from
40
59
  add) or by 12-hex `cron_hash` (shown in `cron_list` output). Both
41
60
  arguments are accepted; pass one.
@@ -23802,7 +23802,7 @@ var init_dist = __esm(() => {
23802
23802
  });
23803
23803
 
23804
23804
  // ../src/config/schema.ts
23805
- var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
23805
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, webhookDispatchRule, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReactionDispatchSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
23806
23806
  var init_schema = __esm(() => {
23807
23807
  init_zod();
23808
23808
  CodeRepoEntrySchema = exports_external.object({
@@ -24078,6 +24078,10 @@ var init_schema = __esm(() => {
24078
24078
  per_hour_cap: exports_external.number().int().nonnegative().optional().describe("Max reaction-triggered synthetic turns per chat per rolling hour. " + "Refusals are stderr-logged but not surfaced to the agent. " + "Default 10. Set to 0 to disable triggering via the cap path."),
24079
24079
  group_admin_only: exports_external.boolean().optional().describe("In groups/supergroups (negative chat_id), only trigger a synthetic " + "turn when the reacter is a chat admin (creator or administrator). " + "Failing the lookup is treated as non-admin (fail-closed). " + "DMs are never affected by this flag \u2014 the reacter IS the user. " + "Default true.")
24080
24080
  }).optional();
24081
+ ReactionDispatchSchema = exports_external.object({
24082
+ enabled: exports_external.boolean().optional().describe("Master switch for the reaction-dispatch path. Default false \u2014 " + "with no reaction_dispatch block, reactions are persisted (and may " + "feed the `reactions` feedback path) but are NEVER dispatched as " + "event-driven inbound turns."),
24083
+ emojis: exports_external.array(exports_external.string()).optional().describe('Emoji allowlist that triggers a `<channel event="reaction">` ' + "inbound turn when reacted to any message. Default [] (nothing " + "fires). Cascade mode: REPLACE (not union) \u2014 a layer's list " + "replaces lower layers entirely so an operator can narrow per-agent.")
24084
+ }).optional();
24081
24085
  ReleaseBlock = exports_external.object({
24082
24086
  channel: exports_external.enum(["dev", "rc", "latest"]).optional(),
24083
24087
  pin: exports_external.string().regex(/^(sha-[0-9a-f]{7,40}|v\d+\.\d+\.\d+)$/).optional()
@@ -24113,6 +24117,7 @@ var init_schema = __esm(() => {
24113
24117
  schedule: exports_external.array(ScheduleEntrySchema).optional(),
24114
24118
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional().describe("Operator-granted STANDING vault keys this agent may read via the " + "broker \u2014 independent of any cron or MCP server. Use when an agent " + "needs a credential both interactively and in its own (agent-managed) " + "schedules, so the grant lives with the agent rather than welded to a " + "specific cron's `secrets[]`. OPERATOR-SET ONLY: agents cannot edit " + "switchroom.yaml or self-grant (reference/vision.md outcome 2 \u2014 'you " + "hold the leash; only your tap grants it'). Exact key names. Cascades " + "UNION across defaults -> profile -> agent (see docs/configuration.md)."),
24115
24119
  reactions: ReactionsSchema,
24120
+ reaction_dispatch: ReactionDispatchSchema,
24116
24121
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only").optional(),
24117
24122
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
24118
24123
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Omit to use Claude's default (acceptEdits for switchroom agents). " + "Warning: bypassPermissions and dontAsk skip all safety checks \u2014 use only in trusted sandboxes."),
@@ -24182,6 +24187,7 @@ var init_schema = __esm(() => {
24182
24187
  schedule: exports_external.array(ScheduleEntrySchema).default([]),
24183
24188
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).optional(),
24184
24189
  reactions: ReactionsSchema,
24190
+ reaction_dispatch: ReactionDispatchSchema,
24185
24191
  model: exports_external.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._\-/\[\]:]*$/, "Model name must be alphanumeric with ._-/[]: only (no spaces or shell specials)").optional().describe("Claude model override (e.g., 'claude-sonnet-4-6')"),
24186
24192
  thinking_effort: exports_external.enum(["low", "medium", "high", "xhigh", "max"]).optional().describe("Adaptive-thinking effort level passed as --effort to the claude CLI. " + "Per-agent override wins over defaults.thinking_effort. " + "lower = faster/cheaper, higher = more reasoning. Omit to use Claude's default."),
24187
24193
  permission_mode: exports_external.enum(["acceptEdits", "auto", "bypassPermissions", "default", "dontAsk", "plan"]).optional().describe("Permission mode passed as --permission-mode to the claude CLI. " + "Per-agent override wins over defaults.permission_mode. " + "Warning: bypassPermissions and dontAsk skip all safety checks \u2014 use only in trusted sandboxes."),
@@ -24857,6 +24863,18 @@ function mergeAgentConfig(defaultsIn, agentIn) {
24857
24863
  }
24858
24864
  merged.reactions = combined;
24859
24865
  }
24866
+ const dReactionDispatch = defaults.reaction_dispatch;
24867
+ const mReactionDispatch = merged.reaction_dispatch;
24868
+ if (dReactionDispatch || mReactionDispatch) {
24869
+ const base = dReactionDispatch ?? {};
24870
+ const override = mReactionDispatch ?? {};
24871
+ const combined = { ...base };
24872
+ for (const [k, v] of Object.entries(override)) {
24873
+ if (v !== undefined)
24874
+ combined[k] = v;
24875
+ }
24876
+ merged.reaction_dispatch = combined;
24877
+ }
24860
24878
  if (defaults.resources || merged.resources) {
24861
24879
  const d = defaults.resources ?? {};
24862
24880
  const a = merged.resources ?? {};
@@ -45770,6 +45788,18 @@ function mergeAgentConfig2(defaultsIn, agentIn) {
45770
45788
  }
45771
45789
  merged.reactions = combined;
45772
45790
  }
45791
+ const dReactionDispatch = defaults.reaction_dispatch;
45792
+ const mReactionDispatch = merged.reaction_dispatch;
45793
+ if (dReactionDispatch || mReactionDispatch) {
45794
+ const base = dReactionDispatch ?? {};
45795
+ const override = mReactionDispatch ?? {};
45796
+ const combined = { ...base };
45797
+ for (const [k, v] of Object.entries(override)) {
45798
+ if (v !== undefined)
45799
+ combined[k] = v;
45800
+ }
45801
+ merged.reaction_dispatch = combined;
45802
+ }
45773
45803
  if (defaults.resources || merged.resources) {
45774
45804
  const d = defaults.resources ?? {};
45775
45805
  const a = merged.resources ?? {};
@@ -49848,6 +49878,50 @@ function escapeBody(s) {
49848
49878
  return s.replace(/</g, "&lt;").replace(/>/g, "&gt;");
49849
49879
  }
49850
49880
 
49881
+ // gateway/reaction-dispatch.ts
49882
+ var REACTION_DISPATCH_DEFAULTS = Object.freeze({
49883
+ enabled: false,
49884
+ emojis: Object.freeze(new Set)
49885
+ });
49886
+ function resolveReactionDispatchConfig(raw) {
49887
+ if (!raw)
49888
+ return REACTION_DISPATCH_DEFAULTS;
49889
+ return {
49890
+ enabled: raw.enabled ?? REACTION_DISPATCH_DEFAULTS.enabled,
49891
+ emojis: raw.emojis !== undefined ? new Set(raw.emojis) : REACTION_DISPATCH_DEFAULTS.emojis
49892
+ };
49893
+ }
49894
+ function evaluateReactionDispatch(cfg, c) {
49895
+ if (!cfg.enabled)
49896
+ return { ok: false, reason: "disabled" };
49897
+ if (c.emoji === null)
49898
+ return { ok: false, reason: "no_emoji" };
49899
+ if (!cfg.emojis.has(c.emoji))
49900
+ return { ok: false, reason: "emoji_not_in_allowlist" };
49901
+ return { ok: true };
49902
+ }
49903
+ function buildReactionDispatchInbound(input) {
49904
+ const safeEmoji = escapeAttr2(input.emoji);
49905
+ const safeUser = escapeAttr2(input.user);
49906
+ const safeChat = escapeAttr2(input.chatId);
49907
+ const body = escapeBody2(input.reactedText);
49908
+ const text = `<channel source="switchroom-telegram" event="reaction" ` + `emoji="${safeEmoji}" message_id="${input.messageId}" ` + `chat_id="${safeChat}" user="${safeUser}">` + body + `</channel>`;
49909
+ const meta = {
49910
+ source: "switchroom-telegram",
49911
+ event: "reaction",
49912
+ reaction_emoji: input.emoji,
49913
+ target_message_id: String(input.messageId),
49914
+ reacted_text: input.reactedText
49915
+ };
49916
+ return { text, meta };
49917
+ }
49918
+ function escapeAttr2(s) {
49919
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
49920
+ }
49921
+ function escapeBody2(s) {
49922
+ return s.replace(/</g, "&lt;").replace(/>/g, "&gt;");
49923
+ }
49924
+
49851
49925
  // gateway/pid-file.ts
49852
49926
  import { writeFileSync as writeFileSync11, readFileSync as readFileSync18, unlinkSync as unlinkSync7, renameSync as renameSync6 } from "node:fs";
49853
49927
  function writePidFile(path, record) {
@@ -53683,10 +53757,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
53683
53757
  }
53684
53758
 
53685
53759
  // ../src/build-info.ts
53686
- var VERSION = "0.15.7";
53687
- var COMMIT_SHA = "c0a8b988";
53688
- var COMMIT_DATE = "2026-06-12T04:28:04Z";
53689
- var LATEST_PR = 2286;
53760
+ var VERSION = "0.15.9";
53761
+ var COMMIT_SHA = "6ed776e2";
53762
+ var COMMIT_DATE = "2026-06-13T00:47:35Z";
53763
+ var LATEST_PR = 2300;
53690
53764
  var COMMITS_AHEAD_OF_TAG = 0;
53691
53765
 
53692
53766
  // gateway/boot-version.ts
@@ -65034,6 +65108,84 @@ function getReactionDebounce() {
65034
65108
  }
65035
65109
  return reactionDebounce;
65036
65110
  }
65111
+ var reactionDispatchCfg = null;
65112
+ function getReactionDispatchConfig() {
65113
+ if (reactionDispatchCfg)
65114
+ return reactionDispatchCfg;
65115
+ let raw = undefined;
65116
+ try {
65117
+ const cfg = loadConfig2();
65118
+ const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
65119
+ if (agentName3) {
65120
+ const rawAgent = cfg.agents?.[agentName3];
65121
+ if (rawAgent) {
65122
+ const resolved = resolveAgentConfig2(cfg.defaults, cfg.profiles, rawAgent);
65123
+ raw = resolved.reaction_dispatch;
65124
+ }
65125
+ }
65126
+ } catch (err) {
65127
+ process.stderr.write(`telegram gateway: reaction_dispatch: config load failed, defaulting OFF: ${err.message}
65128
+ `);
65129
+ }
65130
+ reactionDispatchCfg = resolveReactionDispatchConfig(raw ?? null);
65131
+ return reactionDispatchCfg;
65132
+ }
65133
+ function maybeDispatchReaction(args) {
65134
+ const cfg = getReactionDispatchConfig();
65135
+ const decision = evaluateReactionDispatch(cfg, { emoji: args.emoji, action: args.action });
65136
+ if (!decision.ok) {
65137
+ if (decision.reason === "emoji_not_in_allowlist" && cfg.enabled) {
65138
+ process.stderr.write(`telegram gateway: reaction_dispatch.reject reason=allowlist_miss emoji=${args.emoji} chat=${args.chatId}
65139
+ `);
65140
+ }
65141
+ return;
65142
+ }
65143
+ const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
65144
+ if (!agentName3) {
65145
+ process.stderr.write(`telegram gateway: reaction_dispatch: skipped \u2014 SWITCHROOM_AGENT_NAME unset
65146
+ `);
65147
+ return;
65148
+ }
65149
+ let reactedText = "";
65150
+ if (HISTORY_ENABLED) {
65151
+ try {
65152
+ const row = lookupMessageRoleAndText(args.chatId, args.messageId);
65153
+ reactedText = row?.text ?? "";
65154
+ } catch (err) {
65155
+ process.stderr.write(`telegram gateway: reaction_dispatch: history lookup failed: ${err}
65156
+ `);
65157
+ }
65158
+ }
65159
+ const { text, meta } = buildReactionDispatchInbound({
65160
+ emoji: args.emoji,
65161
+ chatId: args.chatId,
65162
+ messageId: args.messageId,
65163
+ user: args.user,
65164
+ userId: args.userId,
65165
+ reactedText,
65166
+ ...typeof args.threadId === "number" ? { threadId: args.threadId } : {}
65167
+ });
65168
+ const ts = Date.now();
65169
+ const inbound = {
65170
+ type: "inbound",
65171
+ chatId: args.chatId,
65172
+ ...typeof args.threadId === "number" ? { threadId: args.threadId } : {},
65173
+ messageId: ts,
65174
+ user: args.user,
65175
+ userId: args.userId,
65176
+ ts,
65177
+ text,
65178
+ meta
65179
+ };
65180
+ const delivered = ipcServer.sendToAgent(agentName3, inbound);
65181
+ if (delivered)
65182
+ markClaudeBusyForInbound(inbound);
65183
+ process.stderr.write(`telegram gateway: reaction_dispatch agent=${agentName3} chat=${args.chatId} emoji=${args.emoji} message_id=${args.messageId} delivered=${delivered}
65184
+ `);
65185
+ if (!delivered) {
65186
+ pendingInboundBuffer.push(agentName3, inbound);
65187
+ }
65188
+ }
65037
65189
  function flushReactionBatch(batch) {
65038
65190
  const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
65039
65191
  if (!agentName3) {
@@ -65109,11 +65261,21 @@ async function handleMessageReaction(ctx) {
65109
65261
  `);
65110
65262
  if (action === "remove" || emoji === null)
65111
65263
  return;
65112
- if (!HISTORY_ENABLED)
65113
- return;
65114
65264
  const reacter = update.user;
65115
65265
  if (!reacter)
65116
65266
  return;
65267
+ const reacterName = reacter.first_name ?? reacter.username ?? String(reacter.id);
65268
+ maybeDispatchReaction({
65269
+ chatId: chat_id,
65270
+ messageId: message_id,
65271
+ emoji,
65272
+ action,
65273
+ user: reacterName,
65274
+ userId: reacter.id,
65275
+ ...typeof update.message_thread_id === "number" ? { threadId: update.message_thread_id } : {}
65276
+ });
65277
+ if (!HISTORY_ENABLED)
65278
+ return;
65117
65279
  const cfg = getReactionsConfig();
65118
65280
  if (!cfg.enabled)
65119
65281
  return;
@@ -65163,7 +65325,7 @@ async function handleMessageReaction(ctx) {
65163
65325
  ts: Date.now(),
65164
65326
  preview,
65165
65327
  userId: reacter.id,
65166
- user: reacter.first_name ?? reacter.username ?? String(reacter.id),
65328
+ user: reacterName,
65167
65329
  ...typeof update.message_thread_id === "number" ? { threadId: update.message_thread_id } : {}
65168
65330
  };
65169
65331
  getReactionDebounce().enqueue(update.chat.id, pending2);
@@ -366,6 +366,7 @@ import type {
366
366
  PermissionEvent,
367
367
  } from './ipc-protocol.js'
368
368
  import { DebounceBuffer, HourCap, buildReactionInboundMeta, buildReactionInboundText, evaluateTriggerCandidate, isGroupChat, resolveReactionsConfig, truncatePreview, type PendingReaction, type ReactionBatch, type ReactionsResolvedConfig } from './reaction-trigger.js'
369
+ import { buildReactionDispatchInbound, evaluateReactionDispatch, resolveReactionDispatchConfig, type ReactionDispatchResolvedConfig } from './reaction-dispatch.js'
369
370
  import { writePidFile, clearPidFile } from './pid-file.js'
370
371
  import { acquireStartupLock, releaseStartupLock } from './startup-mutex.js'
371
372
  import { drainShutdown } from './shutdown-drain.js'
@@ -19825,6 +19826,120 @@ function getReactionDebounce(): DebounceBuffer {
19825
19826
  return reactionDebounce
19826
19827
  }
19827
19828
 
19829
+ // ─── reaction_dispatch (#2291) ─────────────────────────────────────────────
19830
+ // Event-driven dispatch of ANY qualifying message_reaction as an inbound
19831
+ // `<channel event="reaction">` turn. Default OFF; independent of the
19832
+ // bot-authored `reactions` feedback path above.
19833
+ let reactionDispatchCfg: ReactionDispatchResolvedConfig | null = null
19834
+
19835
+ function getReactionDispatchConfig(): ReactionDispatchResolvedConfig {
19836
+ if (reactionDispatchCfg) return reactionDispatchCfg
19837
+ let raw: unknown = undefined
19838
+ try {
19839
+ const cfg = loadSwitchroomConfig()
19840
+ const agentName = process.env.SWITCHROOM_AGENT_NAME
19841
+ if (agentName) {
19842
+ const rawAgent = cfg.agents?.[agentName]
19843
+ if (rawAgent) {
19844
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
19845
+ raw = (resolved as { reaction_dispatch?: unknown }).reaction_dispatch
19846
+ }
19847
+ }
19848
+ } catch (err) {
19849
+ process.stderr.write(
19850
+ `telegram gateway: reaction_dispatch: config load failed, defaulting OFF: ${(err as Error).message}\n`,
19851
+ )
19852
+ }
19853
+ reactionDispatchCfg = resolveReactionDispatchConfig(
19854
+ raw as Parameters<typeof resolveReactionDispatchConfig>[0] ?? null,
19855
+ )
19856
+ return reactionDispatchCfg
19857
+ }
19858
+
19859
+ /**
19860
+ * Event-driven reaction → inbound turn (#2291). Called from the
19861
+ * message_reaction handler for add/change events. Filters by the
19862
+ * `reaction_dispatch` emoji allowlist (default empty ⇒ no-op), looks up
19863
+ * the reacted message's text from the SQLite history buffer (graceful on
19864
+ * miss), and injects an inbound shaped like a button-callback event via
19865
+ * the same ipcServer.sendToAgent path cron uses. Removals never reach
19866
+ * here (the caller filters them).
19867
+ */
19868
+ function maybeDispatchReaction(args: {
19869
+ chatId: string
19870
+ messageId: number
19871
+ emoji: string | null
19872
+ action: 'add' | 'change'
19873
+ user: string
19874
+ userId: number
19875
+ threadId?: number
19876
+ }): void {
19877
+ const cfg = getReactionDispatchConfig()
19878
+ const decision = evaluateReactionDispatch(cfg, { emoji: args.emoji, action: args.action })
19879
+ if (!decision.ok) {
19880
+ if (decision.reason === 'emoji_not_in_allowlist' && cfg.enabled) {
19881
+ process.stderr.write(
19882
+ `telegram gateway: reaction_dispatch.reject reason=allowlist_miss emoji=${args.emoji} chat=${args.chatId}\n`,
19883
+ )
19884
+ }
19885
+ return
19886
+ }
19887
+
19888
+ const agentName = process.env.SWITCHROOM_AGENT_NAME
19889
+ if (!agentName) {
19890
+ process.stderr.write(
19891
+ `telegram gateway: reaction_dispatch: skipped — SWITCHROOM_AGENT_NAME unset\n`,
19892
+ )
19893
+ return
19894
+ }
19895
+
19896
+ // History lookup is best-effort; a miss yields an empty body. The
19897
+ // envelope still carries emoji / message_id / chat_id / user.
19898
+ let reactedText = ''
19899
+ if (HISTORY_ENABLED) {
19900
+ try {
19901
+ const row = lookupMessageRoleAndText(args.chatId, args.messageId)
19902
+ reactedText = row?.text ?? ''
19903
+ } catch (err) {
19904
+ process.stderr.write(
19905
+ `telegram gateway: reaction_dispatch: history lookup failed: ${err}\n`,
19906
+ )
19907
+ }
19908
+ }
19909
+
19910
+ const { text, meta } = buildReactionDispatchInbound({
19911
+ emoji: args.emoji!,
19912
+ chatId: args.chatId,
19913
+ messageId: args.messageId,
19914
+ user: args.user,
19915
+ userId: args.userId,
19916
+ reactedText,
19917
+ ...(typeof args.threadId === 'number' ? { threadId: args.threadId } : {}),
19918
+ })
19919
+
19920
+ const ts = Date.now()
19921
+ const inbound: InboundMessage = {
19922
+ type: 'inbound',
19923
+ chatId: args.chatId,
19924
+ ...(typeof args.threadId === 'number' ? { threadId: args.threadId } : {}),
19925
+ messageId: ts,
19926
+ user: args.user,
19927
+ userId: args.userId,
19928
+ ts,
19929
+ text,
19930
+ meta,
19931
+ }
19932
+ const delivered = ipcServer.sendToAgent(agentName, inbound)
19933
+ if (delivered) markClaudeBusyForInbound(inbound)
19934
+ process.stderr.write(
19935
+ `telegram gateway: reaction_dispatch agent=${agentName} chat=${args.chatId} ` +
19936
+ `emoji=${args.emoji} message_id=${args.messageId} delivered=${delivered}\n`,
19937
+ )
19938
+ if (!delivered) {
19939
+ pendingInboundBuffer.push(agentName, inbound)
19940
+ }
19941
+ }
19942
+
19828
19943
  /**
19829
19944
  * Dispatch a debounce-flushed batch as a synthetic InboundMessage via
19830
19945
  * the same `ipcServer.sendToAgent` path the cron-fold-in uses.
@@ -19949,10 +20064,29 @@ async function handleMessageReaction(ctx: Context): Promise<void> {
19949
20064
  // From here on we ignore failures rather than reject (the persist
19950
20065
  // path above is the v1 contract; trigger is best-effort).
19951
20066
  if (action === 'remove' || emoji === null) return
19952
- if (!HISTORY_ENABLED) return // need history to identify bot-authored target
19953
20067
  const reacter = update.user
19954
20068
  if (!reacter) return // anonymous group-channel reactions — not user-attributable
19955
20069
 
20070
+ const reacterName = reacter.first_name ?? reacter.username ?? String(reacter.id)
20071
+
20072
+ // ─── reaction_dispatch (#2291) ───────────────────────────────────────
20073
+ // Event-driven dispatch of ANY qualifying reaction as a button-style
20074
+ // inbound turn. Independent of (and runs before) the bot-authored
20075
+ // `reactions` feedback path below; both may fire for one reaction.
20076
+ maybeDispatchReaction({
20077
+ chatId: chat_id,
20078
+ messageId: message_id,
20079
+ emoji,
20080
+ action,
20081
+ user: reacterName,
20082
+ userId: reacter.id,
20083
+ ...(typeof update.message_thread_id === 'number'
20084
+ ? { threadId: update.message_thread_id }
20085
+ : {}),
20086
+ })
20087
+
20088
+ if (!HISTORY_ENABLED) return // need history to identify bot-authored target
20089
+
19956
20090
  const cfg = getReactionsConfig()
19957
20091
  if (!cfg.enabled) return
19958
20092
 
@@ -20025,7 +20159,7 @@ async function handleMessageReaction(ctx: Context): Promise<void> {
20025
20159
  ts: Date.now(),
20026
20160
  preview,
20027
20161
  userId: reacter.id,
20028
- user: reacter.first_name ?? reacter.username ?? String(reacter.id),
20162
+ user: reacterName,
20029
20163
  ...(typeof update.message_thread_id === 'number'
20030
20164
  ? { threadId: update.message_thread_id }
20031
20165
  : {}),