switchroom 0.15.13 → 0.15.14

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.
@@ -13724,11 +13724,29 @@ var PollSpecSchema = exports_external.discriminatedUnion("type", [
13724
13724
  HttpDiffPollSchema,
13725
13725
  TelegramReactionsPollSchema
13726
13726
  ]);
13727
+ var TelegramMessageActionSchema = exports_external.object({
13728
+ type: exports_external.literal("telegram-message"),
13729
+ text: exports_external.string().min(1).describe("Operator-authored message text. Posts to the AGENT'S OWN chat only " + "(the chat is not configurable — fenced by construction), into the " + "entry-level `topic` when set. Supports ONLY deterministic placeholders " + "{{date}} / {{time}} (UTC, from the fire clock) and {{agent}}. NO vault " + "secrets (use a webhook action for secret-bearing requests) and NO model " + "output — the text is fully determined by config."),
13730
+ parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
13731
+ });
13732
+ var WebhookActionSchema = exports_external.object({
13733
+ type: exports_external.literal("webhook"),
13734
+ url: exports_external.string().url().describe("Webhook target. SAME egress fence as an http-diff poll (§6.1): https " + "only, host on the operator egress allowlist, loopback/private/link-local " + "rejected, resolved IP DNS-rebind-pinned. Not agent-writable without an " + "operator commit."),
13735
+ method: exports_external.enum(["GET", "POST"]).default("POST"),
13736
+ headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
13737
+ body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
13738
+ 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")).default([]).describe("Vault keys this webhook may inject into headers/body via {{name}}. Each " + "is HOST-PINNED (§6.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved action cannot exfil it elsewhere.")
13739
+ });
13740
+ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
13741
+ TelegramMessageActionSchema,
13742
+ WebhookActionSchema
13743
+ ]);
13727
13744
  var ScheduleEntrySchema = exports_external.object({
13728
13745
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
13729
- prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
13730
- kind: exports_external.enum(["poll", "prompt"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. Honoured only when SWITCHROOM_CHEAP_CRON is on."),
13746
+ prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
13747
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
13731
13748
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
13749
+ action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
13732
13750
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
13733
13751
  context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
13734
13752
  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")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
@@ -13745,13 +13763,41 @@ var ScheduleEntrySchema = exports_external.object({
13745
13763
  message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
13746
13764
  });
13747
13765
  }
13748
- if (kind === "prompt" && entry.poll) {
13766
+ if (kind !== "poll" && entry.poll) {
13749
13767
  ctx.addIssue({
13750
13768
  code: exports_external.ZodIssueCode.custom,
13751
13769
  path: ["poll"],
13752
13770
  message: "`poll` is only valid when kind: poll."
13753
13771
  });
13754
13772
  }
13773
+ if (kind === "action" && !entry.action) {
13774
+ ctx.addIssue({
13775
+ code: exports_external.ZodIssueCode.custom,
13776
+ path: ["action"],
13777
+ message: "kind: action requires an `action` spec (telegram-message or webhook)."
13778
+ });
13779
+ }
13780
+ if (kind !== "action" && entry.action) {
13781
+ ctx.addIssue({
13782
+ code: exports_external.ZodIssueCode.custom,
13783
+ path: ["action"],
13784
+ message: "`action` is only valid when kind: action."
13785
+ });
13786
+ }
13787
+ if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
13788
+ ctx.addIssue({
13789
+ code: exports_external.ZodIssueCode.custom,
13790
+ path: ["prompt"],
13791
+ message: `kind: ${kind} requires a non-empty \`prompt\`.`
13792
+ });
13793
+ }
13794
+ if (kind === "action" && entry.prompt !== undefined) {
13795
+ ctx.addIssue({
13796
+ code: exports_external.ZodIssueCode.custom,
13797
+ path: ["prompt"],
13798
+ message: "`prompt` is not valid for kind: action (an action never fires a model)."
13799
+ });
13800
+ }
13755
13801
  });
13756
13802
  var AgentSoulSchema = exports_external.object({
13757
13803
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -11299,7 +11299,7 @@ var init_dist = __esm(() => {
11299
11299
  });
11300
11300
 
11301
11301
  // src/config/schema.ts
11302
- 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;
11302
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, 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;
11303
11303
  var init_schema = __esm(() => {
11304
11304
  init_zod();
11305
11305
  CodeRepoEntrySchema = exports_external.object({
@@ -11332,11 +11332,29 @@ var init_schema = __esm(() => {
11332
11332
  HttpDiffPollSchema,
11333
11333
  TelegramReactionsPollSchema
11334
11334
  ]);
11335
+ TelegramMessageActionSchema = exports_external.object({
11336
+ type: exports_external.literal("telegram-message"),
11337
+ text: exports_external.string().min(1).describe("Operator-authored message text. Posts to the AGENT'S OWN chat only " + "(the chat is not configurable — fenced by construction), into the " + "entry-level `topic` when set. Supports ONLY deterministic placeholders " + "{{date}} / {{time}} (UTC, from the fire clock) and {{agent}}. NO vault " + "secrets (use a webhook action for secret-bearing requests) and NO model " + "output — the text is fully determined by config."),
11338
+ parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
11339
+ });
11340
+ WebhookActionSchema = exports_external.object({
11341
+ type: exports_external.literal("webhook"),
11342
+ url: exports_external.string().url().describe("Webhook target. SAME egress fence as an http-diff poll (§6.1): https " + "only, host on the operator egress allowlist, loopback/private/link-local " + "rejected, resolved IP DNS-rebind-pinned. Not agent-writable without an " + "operator commit."),
11343
+ method: exports_external.enum(["GET", "POST"]).default("POST"),
11344
+ headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
11345
+ body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
11346
+ 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")).default([]).describe("Vault keys this webhook may inject into headers/body via {{name}}. Each " + "is HOST-PINNED (§6.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved action cannot exfil it elsewhere.")
11347
+ });
11348
+ ActionSpecSchema = exports_external.discriminatedUnion("type", [
11349
+ TelegramMessageActionSchema,
11350
+ WebhookActionSchema
11351
+ ]);
11335
11352
  ScheduleEntrySchema = exports_external.object({
11336
11353
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11337
- prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
11338
- kind: exports_external.enum(["poll", "prompt"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. Honoured only when SWITCHROOM_CHEAP_CRON is on."),
11354
+ prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
11355
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
11339
11356
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11357
+ action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
11340
11358
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
11341
11359
  context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
11342
11360
  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")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
@@ -11353,13 +11371,41 @@ var init_schema = __esm(() => {
11353
11371
  message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11354
11372
  });
11355
11373
  }
11356
- if (kind === "prompt" && entry.poll) {
11374
+ if (kind !== "poll" && entry.poll) {
11357
11375
  ctx.addIssue({
11358
11376
  code: exports_external.ZodIssueCode.custom,
11359
11377
  path: ["poll"],
11360
11378
  message: "`poll` is only valid when kind: poll."
11361
11379
  });
11362
11380
  }
11381
+ if (kind === "action" && !entry.action) {
11382
+ ctx.addIssue({
11383
+ code: exports_external.ZodIssueCode.custom,
11384
+ path: ["action"],
11385
+ message: "kind: action requires an `action` spec (telegram-message or webhook)."
11386
+ });
11387
+ }
11388
+ if (kind !== "action" && entry.action) {
11389
+ ctx.addIssue({
11390
+ code: exports_external.ZodIssueCode.custom,
11391
+ path: ["action"],
11392
+ message: "`action` is only valid when kind: action."
11393
+ });
11394
+ }
11395
+ if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
11396
+ ctx.addIssue({
11397
+ code: exports_external.ZodIssueCode.custom,
11398
+ path: ["prompt"],
11399
+ message: `kind: ${kind} requires a non-empty \`prompt\`.`
11400
+ });
11401
+ }
11402
+ if (kind === "action" && entry.prompt !== undefined) {
11403
+ ctx.addIssue({
11404
+ code: exports_external.ZodIssueCode.custom,
11405
+ path: ["prompt"],
11406
+ message: "`prompt` is not valid for kind: action (an action never fires a model)."
11407
+ });
11408
+ }
11363
11409
  });
11364
11410
  AgentSoulSchema = exports_external.object({
11365
11411
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -11299,7 +11299,7 @@ var init_zod = __esm(() => {
11299
11299
  });
11300
11300
 
11301
11301
  // src/config/schema.ts
11302
- 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;
11302
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, 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;
11303
11303
  var init_schema = __esm(() => {
11304
11304
  init_zod();
11305
11305
  CodeRepoEntrySchema = exports_external.object({
@@ -11332,11 +11332,29 @@ var init_schema = __esm(() => {
11332
11332
  HttpDiffPollSchema,
11333
11333
  TelegramReactionsPollSchema
11334
11334
  ]);
11335
+ TelegramMessageActionSchema = exports_external.object({
11336
+ type: exports_external.literal("telegram-message"),
11337
+ text: exports_external.string().min(1).describe("Operator-authored message text. Posts to the AGENT'S OWN chat only " + "(the chat is not configurable — fenced by construction), into the " + "entry-level `topic` when set. Supports ONLY deterministic placeholders " + "{{date}} / {{time}} (UTC, from the fire clock) and {{agent}}. NO vault " + "secrets (use a webhook action for secret-bearing requests) and NO model " + "output — the text is fully determined by config."),
11338
+ parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
11339
+ });
11340
+ WebhookActionSchema = exports_external.object({
11341
+ type: exports_external.literal("webhook"),
11342
+ url: exports_external.string().url().describe("Webhook target. SAME egress fence as an http-diff poll (§6.1): https " + "only, host on the operator egress allowlist, loopback/private/link-local " + "rejected, resolved IP DNS-rebind-pinned. Not agent-writable without an " + "operator commit."),
11343
+ method: exports_external.enum(["GET", "POST"]).default("POST"),
11344
+ headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
11345
+ body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
11346
+ 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")).default([]).describe("Vault keys this webhook may inject into headers/body via {{name}}. Each " + "is HOST-PINNED (§6.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved action cannot exfil it elsewhere.")
11347
+ });
11348
+ ActionSpecSchema = exports_external.discriminatedUnion("type", [
11349
+ TelegramMessageActionSchema,
11350
+ WebhookActionSchema
11351
+ ]);
11335
11352
  ScheduleEntrySchema = exports_external.object({
11336
11353
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11337
- prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
11338
- kind: exports_external.enum(["poll", "prompt"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. Honoured only when SWITCHROOM_CHEAP_CRON is on."),
11354
+ prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
11355
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates — zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
11339
11356
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11357
+ action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
11340
11358
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) — the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
11341
11359
  context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' → a minimal-" + "context cheap cron session (Tier 1). 'agent' → the agent's live " + "session with full persona/memory (Tier 2). Unset → inferred from " + "`model` (cheap→fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
11342
11360
  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")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default — broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary — " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
@@ -11353,13 +11371,41 @@ var init_schema = __esm(() => {
11353
11371
  message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11354
11372
  });
11355
11373
  }
11356
- if (kind === "prompt" && entry.poll) {
11374
+ if (kind !== "poll" && entry.poll) {
11357
11375
  ctx.addIssue({
11358
11376
  code: exports_external.ZodIssueCode.custom,
11359
11377
  path: ["poll"],
11360
11378
  message: "`poll` is only valid when kind: poll."
11361
11379
  });
11362
11380
  }
11381
+ if (kind === "action" && !entry.action) {
11382
+ ctx.addIssue({
11383
+ code: exports_external.ZodIssueCode.custom,
11384
+ path: ["action"],
11385
+ message: "kind: action requires an `action` spec (telegram-message or webhook)."
11386
+ });
11387
+ }
11388
+ if (kind !== "action" && entry.action) {
11389
+ ctx.addIssue({
11390
+ code: exports_external.ZodIssueCode.custom,
11391
+ path: ["action"],
11392
+ message: "`action` is only valid when kind: action."
11393
+ });
11394
+ }
11395
+ if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
11396
+ ctx.addIssue({
11397
+ code: exports_external.ZodIssueCode.custom,
11398
+ path: ["prompt"],
11399
+ message: `kind: ${kind} requires a non-empty \`prompt\`.`
11400
+ });
11401
+ }
11402
+ if (kind === "action" && entry.prompt !== undefined) {
11403
+ ctx.addIssue({
11404
+ code: exports_external.ZodIssueCode.custom,
11405
+ path: ["prompt"],
11406
+ message: "`prompt` is not valid for kind: action (an action never fires a model)."
11407
+ });
11408
+ }
11363
11409
  });
11364
11410
  AgentSoulSchema = exports_external.object({
11365
11411
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.13",
3
+ "version": "0.15.14",
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": {
@@ -5,10 +5,15 @@
5
5
  # container, dedicated to cheap cron fires. It registers to the SAME gateway
6
6
  # as a DISTINCT bridge identity `<name>-cron` (start.sh's gateway sidecar
7
7
  # routes meta.session=cron fires here and gates all status surfaces off this
8
- # identity — see telegram-plugin/gateway/cron-session.ts). Minimal context:
9
- # its own CLAUDE_CONFIG_DIR (separate transcript) + a TRIMMED .mcp.json (only
10
- # switchroom-telegram) it pays a fraction of the main session's schema +
11
- # cache-read tax, at the cheap cron model.
8
+ # identity — see telegram-plugin/gateway/cron-session.ts).
9
+ #
10
+ # Tier-1 (#2307) is "a fresh conversation that STILL has the agent's memory +
11
+ # tools, on a cheaper model." It loads the agent's FULL .mcp.json (its own
12
+ # .claude-cron/.mcp.json — same hindsight bank baked from the BASE name, same
13
+ # tools) with ENABLE_TOOL_SEARCH so the schema set DEFERS (low context cost).
14
+ # The saving is dropping the accumulated main-session conversation context +
15
+ # the cheaper model — NOT lobotomising the session. Its CLAUDE_CONFIG_DIR is
16
+ # separate (own transcript) so each fire starts fresh and /clear-friendly.
12
17
  #
13
18
  # Forked as a supervised sidecar by start.sh ONLY when {{name}} actually has a
14
19
  # Tier-1 (context: fresh) cron entry AND SWITCHROOM_CHEAP_CRON is on — so the
@@ -35,12 +40,13 @@ esac
35
40
  CRON_NAME="{{name}}-cron"
36
41
  CRON_CONFIG_DIR="{{agentDir}}/.claude-cron"
37
42
  MAIN_CONFIG_DIR="{{agentDir}}/.claude"
38
- # TRIMMED MCP config (scaffold writes .claude-cron/.mcp.json with ONLY
39
- # switchroom-telegram) the cron session pays a fraction of the main session's
40
- # ~31k-token MCP schema tax. The switchroom-telegram BRIDGE reads
41
- # SWITCHROOM_AGENT_NAME from the process env (exported below), NOT its .mcp.json
42
- # env block, so it registers as the cron identity. Fall back to the full
43
- # .mcp.json if the trimmed file is missing (older scaffold) so it still boots.
43
+ # Cron .mcp.json (scaffold writes .claude-cron/.mcp.json with the agent's FULL
44
+ # filtered MCP set same hindsight bank, same tools — but the switchroom-
45
+ # telegram entry carries a DISTINCT SWITCHROOM_BRIDGE_ALIVE_PATH so its liveness
46
+ # file can't mask the main bridge). The bridge reads SWITCHROOM_AGENT_NAME from
47
+ # the process env (exported below), NOT its .mcp.json env block, so it registers
48
+ # as the cron identity. Fall back to the main .mcp.json if the cron file is
49
+ # missing (older scaffold) so it still boots.
44
50
  CRON_MCP_CONFIG="{{agentDir}}/.claude-cron/.mcp.json"
45
51
  [ -f "$CRON_MCP_CONFIG" ] || CRON_MCP_CONFIG="{{agentDir}}/.mcp.json"
46
52
  CRON_SOCKET="switchroom-${CRON_NAME}"
@@ -63,12 +69,23 @@ fi
63
69
  export SWITCHROOM_AGENT_NAME="$CRON_NAME"
64
70
  export CLAUDE_CONFIG_DIR="$CRON_CONFIG_DIR"
65
71
 
66
- CRON_APPEND_PROMPT="You are the cheap background cron worker for {{name}}. You handle scheduled tasks only. Do the task, reply once with the result, and keep it brief. You do not have {{name}}'s full memory or persona — if a task needs them, say so rather than guessing."
72
+ # Defer the (now full) MCP schema set via tool-search so the cron session keeps
73
+ # its low-context cost — the heavy servers load on demand; switchroom-telegram
74
+ # + hindsight stay alwaysLoad. Normally inherited from start.sh's env; set here
75
+ # as a belt-and-braces default, honouring the operator kill-switch + any value
76
+ # already in the env.
77
+ if [ "${SWITCHROOM_DISABLE_TOOL_SEARCH:-}" != "1" ]; then
78
+ export ENABLE_TOOL_SEARCH="${ENABLE_TOOL_SEARCH:-auto}"
79
+ fi
80
+
81
+ CRON_APPEND_PROMPT="You are the cheap background cron worker for {{name}}. You run scheduled tasks in a fresh, low-context conversation on a cheaper model — but you SHARE {{name}}'s memory (hindsight) and tools, so use them: recall what you need, look things up, reply with real substance. Do the scheduled task, reply once with the result, and keep it tight."
67
82
 
68
83
  # Launch the cron claude in its OWN tmux session/socket so it never contends
69
84
  # with the main session's pane. Interactive (no -p). --strict-mcp-config pins
70
- # it to the trimmed config (switchroom-telegram only). Fresh each boot (no
71
- # --continue) — minimal context by construction.
85
+ # it to the cron .mcp.json (full set, tool-search-deferred). Fresh each boot
86
+ # (no --continue) — low context by construction. cd into the workspace so
87
+ # claude's project key matches the pre-seeded trust state (.claude-cron).
88
+ cd "{{agentDir}}" || exit 1
72
89
  exec tmux -L "$CRON_SOCKET" \
73
90
  new-session -A -s "$CRON_NAME" -x 400 -y 50 \
74
91
  claude \
@@ -827,7 +827,13 @@ async function main(): Promise<void> {
827
827
  onPermission,
828
828
  onStatus,
829
829
  log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}\n`),
830
- livenessFilePath: join(STATE_DIR, ".bridge-alive"),
830
+ // #2307 Tier-1: the cron-session bridge shares the agent's STATE_DIR
831
+ // (access.json / history / gateway.sock) but writes its liveness file to a
832
+ // DISTINCT path (SWITCHROOM_BRIDGE_ALIVE_PATH, set in the cron .mcp.json) so
833
+ // a live <agent>-cron bridge can't mask a dead MAIN bridge in the
834
+ // dashboard/doctor liveness probe (RISK #2). Unset ⟹ the main bridge's
835
+ // canonical STATE_DIR/.bridge-alive, exactly as before.
836
+ livenessFilePath: process.env.SWITCHROOM_BRIDGE_ALIVE_PATH ?? join(STATE_DIR, ".bridge-alive"),
831
837
  })
832
838
  if (ipc.isConnected()) {
833
839
  process.stderr.write(`telegram bridge: connected to gateway at ${SOCKET_PATH}\n`)
@@ -25206,7 +25206,7 @@ async function main() {
25206
25206
  onStatus,
25207
25207
  log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}
25208
25208
  `),
25209
- livenessFilePath: join4(STATE_DIR, ".bridge-alive")
25209
+ livenessFilePath: process.env.SWITCHROOM_BRIDGE_ALIVE_PATH ?? join4(STATE_DIR, ".bridge-alive")
25210
25210
  });
25211
25211
  if (ipc.isConnected()) {
25212
25212
  process.stderr.write(`telegram bridge: connected to gateway at ${SOCKET_PATH}
@@ -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, ReactionDispatchSchema, 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, TelegramMessageActionSchema, WebhookActionSchema, ActionSpecSchema, 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({
@@ -23835,11 +23835,29 @@ var init_schema = __esm(() => {
23835
23835
  HttpDiffPollSchema,
23836
23836
  TelegramReactionsPollSchema
23837
23837
  ]);
23838
+ TelegramMessageActionSchema = exports_external.object({
23839
+ type: exports_external.literal("telegram-message"),
23840
+ text: exports_external.string().min(1).describe("Operator-authored message text. Posts to the AGENT'S OWN chat only " + "(the chat is not configurable \u2014 fenced by construction), into the " + "entry-level `topic` when set. Supports ONLY deterministic placeholders " + "{{date}} / {{time}} (UTC, from the fire clock) and {{agent}}. NO vault " + "secrets (use a webhook action for secret-bearing requests) and NO model " + "output \u2014 the text is fully determined by config."),
23841
+ parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
23842
+ });
23843
+ WebhookActionSchema = exports_external.object({
23844
+ type: exports_external.literal("webhook"),
23845
+ url: exports_external.string().url().describe("Webhook target. SAME egress fence as an http-diff poll (\u00a76.1): https " + "only, host on the operator egress allowlist, loopback/private/link-local " + "rejected, resolved IP DNS-rebind-pinned. Not agent-writable without an " + "operator commit."),
23846
+ method: exports_external.enum(["GET", "POST"]).default("POST"),
23847
+ headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
23848
+ body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
23849
+ 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")).default([]).describe("Vault keys this webhook may inject into headers/body via {{name}}. Each " + "is HOST-PINNED (\u00a76.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved action cannot exfil it elsewhere.")
23850
+ });
23851
+ ActionSpecSchema = exports_external.discriminatedUnion("type", [
23852
+ TelegramMessageActionSchema,
23853
+ WebhookActionSchema
23854
+ ]);
23838
23855
  ScheduleEntrySchema = exports_external.object({
23839
23856
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
23840
- prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
23841
- kind: exports_external.enum(["poll", "prompt"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. Honoured only when SWITCHROOM_CHEAP_CRON is on."),
23857
+ prompt: exports_external.string().optional().describe("Prompt to send at the scheduled time (the escalation prompt when " + "kind=poll; templated with {{diff}}). Required for kind prompt/poll; " + "absent for kind=action (an action has no model fire, so no prompt)."),
23858
+ kind: exports_external.enum(["poll", "prompt", "action"]).optional().describe("Tier-0 routing (docs/rfcs/cheap-cron-sessions.md). 'prompt' (default) " + "fires a model turn every tick (Tier 1/2 per `context`). 'poll' runs a " + "model-free deterministic check (requires `poll`) and only escalates to " + "a model fire on a hit. 'action' runs a model-free deterministic verb " + "(requires `action`) that COMPLETES the work and never escalates \u2014 zero " + "tokens, no session. poll/prompt are honoured only when " + "SWITCHROOM_CHEAP_CRON is on; an action is model-free regardless (the " + "kill-switch governs model tiering, not deterministic actions)."),
23842
23859
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
23860
+ action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
23843
23861
  model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) \u2014 the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
23844
23862
  context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
23845
23863
  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")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
@@ -23856,13 +23874,41 @@ var init_schema = __esm(() => {
23856
23874
  message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
23857
23875
  });
23858
23876
  }
23859
- if (kind === "prompt" && entry.poll) {
23877
+ if (kind !== "poll" && entry.poll) {
23860
23878
  ctx.addIssue({
23861
23879
  code: exports_external.ZodIssueCode.custom,
23862
23880
  path: ["poll"],
23863
23881
  message: "`poll` is only valid when kind: poll."
23864
23882
  });
23865
23883
  }
23884
+ if (kind === "action" && !entry.action) {
23885
+ ctx.addIssue({
23886
+ code: exports_external.ZodIssueCode.custom,
23887
+ path: ["action"],
23888
+ message: "kind: action requires an `action` spec (telegram-message or webhook)."
23889
+ });
23890
+ }
23891
+ if (kind !== "action" && entry.action) {
23892
+ ctx.addIssue({
23893
+ code: exports_external.ZodIssueCode.custom,
23894
+ path: ["action"],
23895
+ message: "`action` is only valid when kind: action."
23896
+ });
23897
+ }
23898
+ if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
23899
+ ctx.addIssue({
23900
+ code: exports_external.ZodIssueCode.custom,
23901
+ path: ["prompt"],
23902
+ message: `kind: ${kind} requires a non-empty \`prompt\`.`
23903
+ });
23904
+ }
23905
+ if (kind === "action" && entry.prompt !== undefined) {
23906
+ ctx.addIssue({
23907
+ code: exports_external.ZodIssueCode.custom,
23908
+ path: ["prompt"],
23909
+ message: "`prompt` is not valid for kind: action (an action never fires a model)."
23910
+ });
23911
+ }
23866
23912
  });
23867
23913
  AgentSoulSchema = exports_external.object({
23868
23914
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -46752,6 +46798,17 @@ function createInjectIpcClient(options) {
46752
46798
  return false;
46753
46799
  try {
46754
46800
  return socket.write(JSON.stringify(msg) + `
46801
+ `);
46802
+ } catch (err) {
46803
+ log(`scheduler ipc: write failed: ${err.message}`);
46804
+ return false;
46805
+ }
46806
+ },
46807
+ sendOutbound(msg) {
46808
+ if (!socket || !connected)
46809
+ return false;
46810
+ try {
46811
+ return socket.write(JSON.stringify(msg) + `
46755
46812
  `);
46756
46813
  } catch (err) {
46757
46814
  log(`scheduler ipc: write failed: ${err.message}`);
@@ -47327,6 +47384,19 @@ function validateClientMessage(msg) {
47327
47384
  const inb = m.inbound;
47328
47385
  return inb.type === "inbound" && typeof inb.chatId === "string" && inb.chatId.length > 0 && typeof inb.text === "string" && typeof inb.messageId === "number" && typeof inb.user === "string" && typeof inb.userId === "number" && typeof inb.ts === "number" && typeof inb.meta === "object" && inb.meta !== null;
47329
47386
  }
47387
+ case "send_outbound": {
47388
+ if (typeof m.agentName !== "string" || !AGENT_NAME_RE3.test(m.agentName))
47389
+ return false;
47390
+ if (typeof m.chatId !== "string" || m.chatId.length === 0)
47391
+ return false;
47392
+ if (typeof m.text !== "string" || m.text.length === 0 || m.text.length > 4096)
47393
+ return false;
47394
+ if (m.threadId !== undefined && (typeof m.threadId !== "number" || !Number.isInteger(m.threadId)))
47395
+ return false;
47396
+ if (m.parseMode !== undefined && m.parseMode !== "html" && m.parseMode !== "text")
47397
+ return false;
47398
+ return true;
47399
+ }
47330
47400
  case "quota_wall_detected": {
47331
47401
  if (typeof m.agentName !== "string" || !AGENT_NAME_RE3.test(m.agentName))
47332
47402
  return false;
@@ -47395,6 +47465,7 @@ function createIpcServer(options) {
47395
47465
  onOperatorEvent,
47396
47466
  onPtyPartial,
47397
47467
  onInjectInbound,
47468
+ onSendOutbound,
47398
47469
  onQuotaWallDetected,
47399
47470
  onRequestDriveApproval,
47400
47471
  onRequestMs365Approval,
@@ -47480,6 +47551,10 @@ function createIpcServer(options) {
47480
47551
  if (onInjectInbound)
47481
47552
  onInjectInbound(client3, msg);
47482
47553
  break;
47554
+ case "send_outbound":
47555
+ if (onSendOutbound)
47556
+ onSendOutbound(client3, msg);
47557
+ break;
47483
47558
  case "quota_wall_detected":
47484
47559
  if (onQuotaWallDetected)
47485
47560
  onQuotaWallDetected(client3, msg);
@@ -54091,11 +54166,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54091
54166
  }
54092
54167
 
54093
54168
  // ../src/build-info.ts
54094
- var VERSION = "0.15.13";
54095
- var COMMIT_SHA = "36ba2682";
54096
- var COMMIT_DATE = "2026-06-13T16:49:14+10:00";
54097
- var LATEST_PR = null;
54098
- var COMMITS_AHEAD_OF_TAG = 6;
54169
+ var VERSION = "0.15.14";
54170
+ var COMMIT_SHA = "91ae15d3";
54171
+ var COMMIT_DATE = "2026-06-13T11:51:08Z";
54172
+ var LATEST_PR = 2326;
54173
+ var COMMITS_AHEAD_OF_TAG = 0;
54099
54174
 
54100
54175
  // gateway/boot-version.ts
54101
54176
  function formatRelativeAgo(iso) {
@@ -57690,6 +57765,7 @@ var ipcServer = createIpcServer({
57690
57765
  if (fellBackToMain) {
57691
57766
  process.stderr.write(`telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}
57692
57767
  `);
57768
+ emitRuntimeMetric({ kind: "cron_fell_back_to_main", agent: msg.agentName, prompt_key: promptKey });
57693
57769
  }
57694
57770
  if (delivered && target === msg.agentName)
57695
57771
  markClaudeBusyForInbound(msg.inbound);
@@ -57699,6 +57775,29 @@ var ipcServer = createIpcServer({
57699
57775
  pendingInboundBuffer.push(target, msg.inbound);
57700
57776
  }
57701
57777
  },
57778
+ onSendOutbound(_client, msg) {
57779
+ const self2 = process.env.SWITCHROOM_AGENT_NAME;
57780
+ if (self2 && msg.agentName !== self2) {
57781
+ process.stderr.write(`telegram gateway: send_outbound rejected \u2014 agent mismatch (${msg.agentName} != ${self2})
57782
+ `);
57783
+ return;
57784
+ }
57785
+ try {
57786
+ assertAllowedChat(msg.chatId);
57787
+ } catch (err) {
57788
+ process.stderr.write(`telegram gateway: send_outbound rejected \u2014 ${err.message}
57789
+ `);
57790
+ return;
57791
+ }
57792
+ const threadId = msg.threadId;
57793
+ const parseMode = msg.parseMode === "text" ? undefined : "HTML";
57794
+ swallowingApiCall(() => bot.api.sendMessage(msg.chatId, msg.text, {
57795
+ ...parseMode ? { parse_mode: parseMode } : {},
57796
+ ...threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}
57797
+ }), { chat_id: msg.chatId, verb: "cron-action-send", ...threadId != null ? { threadId } : {} });
57798
+ process.stderr.write(`telegram gateway: send_outbound agent=${msg.agentName} chat=${msg.chatId} thread=${threadId ?? "-"} len=${msg.text.length}
57799
+ `);
57800
+ },
57702
57801
  onQuotaWallDetected(_client, msg) {
57703
57802
  const untilMs = resolveExhaustUntil(msg.resetAt);
57704
57803
  process.stderr.write(`telegram gateway: quota_wall_detected agent=${msg.agentName} until=${new Date(untilMs).toISOString()}` + (msg.resetAt == null ? " (reset unparsed \u2192 +7d default)" : "") + ` \u2014 triggering fleet auto-fallback
@@ -24221,7 +24221,7 @@ async function main() {
24221
24221
  onStatus,
24222
24222
  log: (msg) => process.stderr.write(`telegram bridge: ipc: ${msg}
24223
24223
  `),
24224
- livenessFilePath: join5(STATE_DIR, ".bridge-alive")
24224
+ livenessFilePath: process.env.SWITCHROOM_BRIDGE_ALIVE_PATH ?? join5(STATE_DIR, ".bridge-alive")
24225
24225
  });
24226
24226
  if (ipc.isConnected()) {
24227
24227
  process.stderr.write(`telegram bridge: connected to gateway at ${SOCKET_PATH}