switchroom 0.14.91 → 0.14.93

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.
@@ -13704,15 +13704,54 @@ var AgentBindMountSchema = exports_external.object({
13704
13704
  target: exports_external.string().optional().describe("Container path the source mounts to. Must be absolute. Defaults to " + "the same path as `source` (matches switchroom's existing dual-mount " + "convention so absolute paths in scaffolded scripts Just Work)."),
13705
13705
  mode: exports_external.enum(["ro", "rw"]).optional().describe("Read-only (default) or read-write. Use `rw` only when the agent " + "must mutate the host path (e.g. editing switchroom source). " + "Default: 'ro'.")
13706
13706
  });
13707
+ var HttpDiffPollSchema = exports_external.object({
13708
+ type: exports_external.literal("http-diff"),
13709
+ url: exports_external.string().url().describe("Poll target. Host MUST match the operator egress allowlist (§6.1) — " + "loopback/private/link-local/non-https are rejected; the IP is " + "resolve-then-pinned against DNS-rebind. Not agent-writable without " + "operator commit."),
13710
+ method: exports_external.enum(["GET", "POST"]).default("GET"),
13711
+ headers: exports_external.record(exports_external.string()).optional(),
13712
+ 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 poll may inject into request headers. 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 poll cannot exfil it elsewhere."),
13713
+ diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
13714
+ state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
13715
+ });
13716
+ var TelegramReactionsPollSchema = exports_external.object({
13717
+ type: exports_external.literal("telegram-reactions"),
13718
+ chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
13719
+ emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68‍\uD83D\uDCBB)."),
13720
+ lookback: exports_external.number().int().positive().max(200).default(40),
13721
+ state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
13722
+ });
13723
+ var PollSpecSchema = exports_external.discriminatedUnion("type", [
13724
+ HttpDiffPollSchema,
13725
+ TelegramReactionsPollSchema
13726
+ ]);
13707
13727
  var ScheduleEntrySchema = exports_external.object({
13708
13728
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
13709
- prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
13710
- model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + " this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
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."),
13731
+ poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
13732
+ 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
+ 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."),
13711
13734
  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."),
13712
13735
  topic: exports_external.union([
13713
13736
  exports_external.string().min(1, "topic alias must be non-empty"),
13714
13737
  exports_external.number().int().positive("topic ID must be a positive integer")
13715
13738
  ]).optional().describe("Forum topic this cron fires into when the owning agent is in " + "supergroup-owned mode (channels.telegram.chat_id set). Either a " + 'string alias resolved against `topic_aliases` (e.g. "planning") ' + "or a numeric topic ID. Falls back to the agent's `default_topic_id` " + "when unset. Ignored for agents in fleet-shared or dm_only mode. " + "Alias-resolution happens at config-load — typos surface immediately. " + "See docs/rfcs/supergroup-mode.md.")
13739
+ }).superRefine((entry, ctx) => {
13740
+ const kind = entry.kind ?? "prompt";
13741
+ if (kind === "poll" && !entry.poll) {
13742
+ ctx.addIssue({
13743
+ code: exports_external.ZodIssueCode.custom,
13744
+ path: ["poll"],
13745
+ message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
13746
+ });
13747
+ }
13748
+ if (kind === "prompt" && entry.poll) {
13749
+ ctx.addIssue({
13750
+ code: exports_external.ZodIssueCode.custom,
13751
+ path: ["poll"],
13752
+ message: "`poll` is only valid when kind: poll."
13753
+ });
13754
+ }
13716
13755
  });
13717
13756
  var AgentSoulSchema = exports_external.object({
13718
13757
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -14159,6 +14198,13 @@ var HostdConfigSchema = exports_external.object({
14159
14198
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
14160
14199
  config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
14161
14200
  });
14201
+ var CronEgressSchema = exports_external.object({
14202
+ allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
14203
+ secret_bindings: exports_external.record(exports_external.string(), exports_external.string().min(1)).default({}).describe("secretName → the single host it may be sent to. A poll carrying a secret to any other host is rejected.")
14204
+ });
14205
+ var CronConfigSchema = exports_external.object({
14206
+ egress: CronEgressSchema.optional().describe("SSRF/exfil fence for http-diff polls.")
14207
+ });
14162
14208
  var SwitchroomConfigSchema = exports_external.object({
14163
14209
  switchroom: exports_external.object({
14164
14210
  version: exports_external.literal(1).describe("Config schema version"),
@@ -14207,7 +14253,8 @@ var SwitchroomConfigSchema = exports_external.object({
14207
14253
  profiles: exports_external.record(exports_external.string(), ProfileSchema).optional().describe("Named profile definitions. Agents reference via `extends: <name>`. " + "Inline profiles declared here take priority over filesystem " + "profiles/<name>/ directories when both exist."),
14208
14254
  agents: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
14209
14255
  message: "Agent name must start with a letter/digit, contain only lowercase letters/digits/hyphens/underscores, and be at most 51 characters (Telegram callback_data byte limit)"
14210
- }), AgentSchema).describe("Map of agent name to agent configuration")
14256
+ }), AgentSchema).describe("Map of agent name to agent configuration"),
14257
+ cron: CronConfigSchema.optional().describe("Cheap-cron settings (docs/rfcs/cheap-cron-sessions.md). Operator-owned " + "egress allowlist + host-pinned secret bindings for Tier-0 http-diff " + "polls (§6.1). Required to enable any http-diff poll; not agent-writable.")
14211
14258
  });
14212
14259
 
14213
14260
  // src/config/paths.ts
@@ -11277,7 +11277,7 @@ var init_dist = __esm(() => {
11277
11277
  });
11278
11278
 
11279
11279
  // src/config/schema.ts
11280
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, 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, SwitchroomConfigSchema;
11280
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, 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;
11281
11281
  var init_schema = __esm(() => {
11282
11282
  init_zod();
11283
11283
  CodeRepoEntrySchema = exports_external.object({
@@ -11290,15 +11290,54 @@ var init_schema = __esm(() => {
11290
11290
  target: exports_external.string().optional().describe("Container path the source mounts to. Must be absolute. Defaults to " + "the same path as `source` (matches switchroom's existing dual-mount " + "convention so absolute paths in scaffolded scripts Just Work)."),
11291
11291
  mode: exports_external.enum(["ro", "rw"]).optional().describe("Read-only (default) or read-write. Use `rw` only when the agent " + "must mutate the host path (e.g. editing switchroom source). " + "Default: 'ro'.")
11292
11292
  });
11293
+ HttpDiffPollSchema = exports_external.object({
11294
+ type: exports_external.literal("http-diff"),
11295
+ url: exports_external.string().url().describe("Poll target. Host MUST match the operator egress allowlist (§6.1) — " + "loopback/private/link-local/non-https are rejected; the IP is " + "resolve-then-pinned against DNS-rebind. Not agent-writable without " + "operator commit."),
11296
+ method: exports_external.enum(["GET", "POST"]).default("GET"),
11297
+ headers: exports_external.record(exports_external.string()).optional(),
11298
+ 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 poll may inject into request headers. 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 poll cannot exfil it elsewhere."),
11299
+ diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
11300
+ state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
11301
+ });
11302
+ TelegramReactionsPollSchema = exports_external.object({
11303
+ type: exports_external.literal("telegram-reactions"),
11304
+ chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
11305
+ emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68‍\uD83D\uDCBB)."),
11306
+ lookback: exports_external.number().int().positive().max(200).default(40),
11307
+ state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
11308
+ });
11309
+ PollSpecSchema = exports_external.discriminatedUnion("type", [
11310
+ HttpDiffPollSchema,
11311
+ TelegramReactionsPollSchema
11312
+ ]);
11293
11313
  ScheduleEntrySchema = exports_external.object({
11294
11314
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11295
- prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
11296
- model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + " this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
11315
+ prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
11316
+ 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."),
11317
+ poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11318
+ 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."),
11319
+ 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."),
11297
11320
  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."),
11298
11321
  topic: exports_external.union([
11299
11322
  exports_external.string().min(1, "topic alias must be non-empty"),
11300
11323
  exports_external.number().int().positive("topic ID must be a positive integer")
11301
11324
  ]).optional().describe("Forum topic this cron fires into when the owning agent is in " + "supergroup-owned mode (channels.telegram.chat_id set). Either a " + 'string alias resolved against `topic_aliases` (e.g. "planning") ' + "or a numeric topic ID. Falls back to the agent's `default_topic_id` " + "when unset. Ignored for agents in fleet-shared or dm_only mode. " + "Alias-resolution happens at config-load — typos surface immediately. " + "See docs/rfcs/supergroup-mode.md.")
11325
+ }).superRefine((entry, ctx) => {
11326
+ const kind = entry.kind ?? "prompt";
11327
+ if (kind === "poll" && !entry.poll) {
11328
+ ctx.addIssue({
11329
+ code: exports_external.ZodIssueCode.custom,
11330
+ path: ["poll"],
11331
+ message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11332
+ });
11333
+ }
11334
+ if (kind === "prompt" && entry.poll) {
11335
+ ctx.addIssue({
11336
+ code: exports_external.ZodIssueCode.custom,
11337
+ path: ["poll"],
11338
+ message: "`poll` is only valid when kind: poll."
11339
+ });
11340
+ }
11302
11341
  });
11303
11342
  AgentSoulSchema = exports_external.object({
11304
11343
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -11745,6 +11784,13 @@ var init_schema = __esm(() => {
11745
11784
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11746
11785
  config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
11747
11786
  });
11787
+ CronEgressSchema = exports_external.object({
11788
+ allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
11789
+ secret_bindings: exports_external.record(exports_external.string(), exports_external.string().min(1)).default({}).describe("secretName → the single host it may be sent to. A poll carrying a secret to any other host is rejected.")
11790
+ });
11791
+ CronConfigSchema = exports_external.object({
11792
+ egress: CronEgressSchema.optional().describe("SSRF/exfil fence for http-diff polls.")
11793
+ });
11748
11794
  SwitchroomConfigSchema = exports_external.object({
11749
11795
  switchroom: exports_external.object({
11750
11796
  version: exports_external.literal(1).describe("Config schema version"),
@@ -11793,7 +11839,8 @@ var init_schema = __esm(() => {
11793
11839
  profiles: exports_external.record(exports_external.string(), ProfileSchema).optional().describe("Named profile definitions. Agents reference via `extends: <name>`. " + "Inline profiles declared here take priority over filesystem " + "profiles/<name>/ directories when both exist."),
11794
11840
  agents: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
11795
11841
  message: "Agent name must start with a letter/digit, contain only lowercase letters/digits/hyphens/underscores, and be at most 51 characters (Telegram callback_data byte limit)"
11796
- }), AgentSchema).describe("Map of agent name to agent configuration")
11842
+ }), AgentSchema).describe("Map of agent name to agent configuration"),
11843
+ cron: CronConfigSchema.optional().describe("Cheap-cron settings (docs/rfcs/cheap-cron-sessions.md). Operator-owned " + "egress allowlist + host-pinned secret bindings for Tier-0 http-diff " + "polls (§6.1). Required to enable any http-diff poll; not agent-writable.")
11797
11844
  });
11798
11845
  });
11799
11846
 
@@ -11277,7 +11277,7 @@ var init_zod = __esm(() => {
11277
11277
  });
11278
11278
 
11279
11279
  // src/config/schema.ts
11280
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, 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, SwitchroomConfigSchema;
11280
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, 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;
11281
11281
  var init_schema = __esm(() => {
11282
11282
  init_zod();
11283
11283
  CodeRepoEntrySchema = exports_external.object({
@@ -11290,15 +11290,54 @@ var init_schema = __esm(() => {
11290
11290
  target: exports_external.string().optional().describe("Container path the source mounts to. Must be absolute. Defaults to " + "the same path as `source` (matches switchroom's existing dual-mount " + "convention so absolute paths in scaffolded scripts Just Work)."),
11291
11291
  mode: exports_external.enum(["ro", "rw"]).optional().describe("Read-only (default) or read-write. Use `rw` only when the agent " + "must mutate the host path (e.g. editing switchroom source). " + "Default: 'ro'.")
11292
11292
  });
11293
+ HttpDiffPollSchema = exports_external.object({
11294
+ type: exports_external.literal("http-diff"),
11295
+ url: exports_external.string().url().describe("Poll target. Host MUST match the operator egress allowlist (§6.1) — " + "loopback/private/link-local/non-https are rejected; the IP is " + "resolve-then-pinned against DNS-rebind. Not agent-writable without " + "operator commit."),
11296
+ method: exports_external.enum(["GET", "POST"]).default("GET"),
11297
+ headers: exports_external.record(exports_external.string()).optional(),
11298
+ 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 poll may inject into request headers. 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 poll cannot exfil it elsewhere."),
11299
+ diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
11300
+ state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
11301
+ });
11302
+ TelegramReactionsPollSchema = exports_external.object({
11303
+ type: exports_external.literal("telegram-reactions"),
11304
+ chat_id: exports_external.union([exports_external.string().min(1), exports_external.number().int()]).describe("Chat to scan for reactions (model-free internal gateway query)."),
11305
+ emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68‍\uD83D\uDCBB)."),
11306
+ lookback: exports_external.number().int().positive().max(200).default(40),
11307
+ state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
11308
+ });
11309
+ PollSpecSchema = exports_external.discriminatedUnion("type", [
11310
+ HttpDiffPollSchema,
11311
+ TelegramReactionsPollSchema
11312
+ ]);
11293
11313
  ScheduleEntrySchema = exports_external.object({
11294
11314
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11295
- prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
11296
- model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + " this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
11315
+ prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
11316
+ 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."),
11317
+ poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11318
+ 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."),
11319
+ 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."),
11297
11320
  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."),
11298
11321
  topic: exports_external.union([
11299
11322
  exports_external.string().min(1, "topic alias must be non-empty"),
11300
11323
  exports_external.number().int().positive("topic ID must be a positive integer")
11301
11324
  ]).optional().describe("Forum topic this cron fires into when the owning agent is in " + "supergroup-owned mode (channels.telegram.chat_id set). Either a " + 'string alias resolved against `topic_aliases` (e.g. "planning") ' + "or a numeric topic ID. Falls back to the agent's `default_topic_id` " + "when unset. Ignored for agents in fleet-shared or dm_only mode. " + "Alias-resolution happens at config-load — typos surface immediately. " + "See docs/rfcs/supergroup-mode.md.")
11325
+ }).superRefine((entry, ctx) => {
11326
+ const kind = entry.kind ?? "prompt";
11327
+ if (kind === "poll" && !entry.poll) {
11328
+ ctx.addIssue({
11329
+ code: exports_external.ZodIssueCode.custom,
11330
+ path: ["poll"],
11331
+ message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11332
+ });
11333
+ }
11334
+ if (kind === "prompt" && entry.poll) {
11335
+ ctx.addIssue({
11336
+ code: exports_external.ZodIssueCode.custom,
11337
+ path: ["poll"],
11338
+ message: "`poll` is only valid when kind: poll."
11339
+ });
11340
+ }
11302
11341
  });
11303
11342
  AgentSoulSchema = exports_external.object({
11304
11343
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -11745,6 +11784,13 @@ var init_schema = __esm(() => {
11745
11784
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit §3). Default false — the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
11746
11785
  config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit §5). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
11747
11786
  });
11787
+ CronEgressSchema = exports_external.object({
11788
+ allowed_hosts: exports_external.array(exports_external.string().min(1)).default([]).describe("Hosts a poll may reach (exact, https-only). loopback/private/IP-literal are always rejected."),
11789
+ secret_bindings: exports_external.record(exports_external.string(), exports_external.string().min(1)).default({}).describe("secretName → the single host it may be sent to. A poll carrying a secret to any other host is rejected.")
11790
+ });
11791
+ CronConfigSchema = exports_external.object({
11792
+ egress: CronEgressSchema.optional().describe("SSRF/exfil fence for http-diff polls.")
11793
+ });
11748
11794
  SwitchroomConfigSchema = exports_external.object({
11749
11795
  switchroom: exports_external.object({
11750
11796
  version: exports_external.literal(1).describe("Config schema version"),
@@ -11793,7 +11839,8 @@ var init_schema = __esm(() => {
11793
11839
  profiles: exports_external.record(exports_external.string(), ProfileSchema).optional().describe("Named profile definitions. Agents reference via `extends: <name>`. " + "Inline profiles declared here take priority over filesystem " + "profiles/<name>/ directories when both exist."),
11794
11840
  agents: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
11795
11841
  message: "Agent name must start with a letter/digit, contain only lowercase letters/digits/hyphens/underscores, and be at most 51 characters (Telegram callback_data byte limit)"
11796
- }), AgentSchema).describe("Map of agent name to agent configuration")
11842
+ }), AgentSchema).describe("Map of agent name to agent configuration"),
11843
+ cron: CronConfigSchema.optional().describe("Cheap-cron settings (docs/rfcs/cheap-cron-sessions.md). Operator-owned " + "egress allowlist + host-pinned secret bindings for Tier-0 http-diff " + "polls (§6.1). Required to enable any http-diff poll; not agent-writable.")
11797
11844
  });
11798
11845
  });
11799
11846
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.91",
3
+ "version": "0.14.93",
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": {
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env bash
2
+ # Tier-1 cheap cron SESSION launcher — docs/rfcs/cheap-cron-sessions.md §2.2.
3
+ #
4
+ # A SECOND interactive `claude` (no -p — compliance pillar 3) in the agent
5
+ # container, dedicated to cheap cron fires. It registers to the SAME gateway
6
+ # as a DISTINCT bridge identity `<name>-cron` (start.sh's gateway sidecar
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.
12
+ #
13
+ # Forked as a supervised sidecar by start.sh ONLY when {{name}} actually has a
14
+ # Tier-1 (context: fresh) cron entry AND SWITCHROOM_CHEAP_CRON is on — so the
15
+ # whole feature is inert for the rest of the fleet (the start.sh fork block
16
+ # renders empty). This script is a no-op artifact on disk until then.
17
+ set -u
18
+
19
+ # Runtime kill-switch. The fork is baked into start.sh whenever {{name}} has a
20
+ # context:fresh cron entry (a config property), but the session only actually
21
+ # runs when SWITCHROOM_CHEAP_CRON is on at runtime. Exit 78 (EX_CONFIG) so the
22
+ # supervisor does NOT respawn-loop when the feature is off — a clean idle.
23
+ case "${SWITCHROOM_CHEAP_CRON:-}" in
24
+ 1 | true | on | ON) : ;;
25
+ *)
26
+ echo "cron-session: SWITCHROOM_CHEAP_CRON off — not starting (exit 78, no respawn)" >&2
27
+ exit 78
28
+ ;;
29
+ esac
30
+
31
+ CRON_NAME="{{name}}-cron"
32
+ CRON_CONFIG_DIR="{{agentDir}}/.claude-cron"
33
+ MAIN_CONFIG_DIR="{{agentDir}}/.claude"
34
+ # TRIMMED MCP config (scaffold writes .claude-cron/.mcp.json with ONLY
35
+ # switchroom-telegram) → the cron session pays a fraction of the main session's
36
+ # ~31k-token MCP schema tax. The switchroom-telegram BRIDGE reads
37
+ # SWITCHROOM_AGENT_NAME from the process env (exported below), NOT its .mcp.json
38
+ # env block, so it registers as the cron identity. Fall back to the full
39
+ # .mcp.json if the trimmed file is missing (older scaffold) so it still boots.
40
+ CRON_MCP_CONFIG="{{agentDir}}/.claude-cron/.mcp.json"
41
+ [ -f "$CRON_MCP_CONFIG" ] || CRON_MCP_CONFIG="{{agentDir}}/.mcp.json"
42
+ CRON_SOCKET="switchroom-${CRON_NAME}"
43
+
44
+ mkdir -p "$CRON_CONFIG_DIR"
45
+
46
+ # Share the broker-managed OAuth creds (same subscription) without sharing the
47
+ # main session's transcript/settings. The broker is the sole writer of
48
+ # .credentials.json into the MAIN config dir; symlink it so the cron session
49
+ # authenticates with the same account and re-reads on refresh. Transcript
50
+ # (projects/) stays separate → minimal, /clear-friendly context.
51
+ if [ -e "$MAIN_CONFIG_DIR/.credentials.json" ]; then
52
+ ln -sfn "$MAIN_CONFIG_DIR/.credentials.json" "$CRON_CONFIG_DIR/.credentials.json"
53
+ fi
54
+ if [ -e "$MAIN_CONFIG_DIR/settings.json" ]; then
55
+ ln -sfn "$MAIN_CONFIG_DIR/settings.json" "$CRON_CONFIG_DIR/settings.json"
56
+ fi
57
+
58
+ # Distinct identity + config dir for the bridge that runs inside claude.
59
+ export SWITCHROOM_AGENT_NAME="$CRON_NAME"
60
+ export CLAUDE_CONFIG_DIR="$CRON_CONFIG_DIR"
61
+
62
+ 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."
63
+
64
+ # Launch the cron claude in its OWN tmux session/socket so it never contends
65
+ # with the main session's pane. Interactive (no -p). --strict-mcp-config pins
66
+ # it to the trimmed config (switchroom-telegram only). Fresh each boot (no
67
+ # --continue) — minimal context by construction.
68
+ exec tmux -L "$CRON_SOCKET" \
69
+ new-session -A -s "$CRON_NAME" -x 400 -y 50 \
70
+ claude \
71
+ --dangerously-load-development-channels server:switchroom-telegram \
72
+ --plugin-dir "{{securityPluginDir}}" \
73
+ --mcp-config "$CRON_MCP_CONFIG" \
74
+ --strict-mcp-config \
75
+ --model {{{cronModelQ}}} \
76
+ --append-system-prompt "$CRON_APPEND_PROMPT"{{#if dangerousMode}} \
77
+ --dangerously-skip-permissions{{/if}}
@@ -163,6 +163,19 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
163
163
  bun /opt/switchroom/agent-scheduler/index.js &
164
164
  fi
165
165
 
166
+ {{#if cronSessionEnabled}}
167
+ # 4) cheap cron SESSION (Tier 1, docs/rfcs/cheap-cron-sessions.md §2.2).
168
+ # A SECOND interactive claude (no -p) dedicated to context:fresh cron
169
+ # fires, registering to the gateway as the cron-suffixed bridge. This
170
+ # block is rendered ONLY for an agent that has a Tier-1 cron entry; for
171
+ # every other agent the cronSessionEnabled guard is false so the whole
172
+ # block is absent and their start.sh is byte-identical to before.
173
+ if command -v claude >/dev/null 2>&1 && [ -f "$(dirname "$0")/cron-session.sh" ]; then
174
+ _switchroom_supervise cron-session /var/log/switchroom/cron-session.log \
175
+ bash "$(dirname "$0")/cron-session.sh" &
176
+ fi
177
+ {{/if}}
178
+
166
179
  export SWITCHROOM_DOCKER_TMUX_INNER=1
167
180
  exec tmux -L "switchroom-{{name}}" \
168
181
  new-session -A -s "{{name}}" -x 400 -y 50 \