switchroom 0.14.91 → 0.14.92

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.
@@ -13520,7 +13520,7 @@ var init_zod = __esm(() => {
13520
13520
  });
13521
13521
 
13522
13522
  // src/config/schema.ts
13523
- 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, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
13523
+ 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, DEFAULT_PROFILE = "default", AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
13524
13524
  var init_schema = __esm(() => {
13525
13525
  init_zod();
13526
13526
  CodeRepoEntrySchema = exports_external.object({
@@ -13533,15 +13533,54 @@ var init_schema = __esm(() => {
13533
13533
  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)."),
13534
13534
  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'.")
13535
13535
  });
13536
+ HttpDiffPollSchema = exports_external.object({
13537
+ type: exports_external.literal("http-diff"),
13538
+ url: exports_external.string().url().describe("Poll target. Host MUST match the operator egress allowlist (\u00a76.1) \u2014 " + "loopback/private/link-local/non-https are rejected; the IP is " + "resolve-then-pinned against DNS-rebind. Not agent-writable without " + "operator commit."),
13539
+ method: exports_external.enum(["GET", "POST"]).default("GET"),
13540
+ headers: exports_external.record(exports_external.string()).optional(),
13541
+ 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 (\u00a76.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."),
13542
+ diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
13543
+ state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
13544
+ });
13545
+ TelegramReactionsPollSchema = exports_external.object({
13546
+ type: exports_external.literal("telegram-reactions"),
13547
+ 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)."),
13548
+ emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68\u200d\uD83D\uDCBB)."),
13549
+ lookback: exports_external.number().int().positive().max(200).default(40),
13550
+ state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
13551
+ });
13552
+ PollSpecSchema = exports_external.discriminatedUnion("type", [
13553
+ HttpDiffPollSchema,
13554
+ TelegramReactionsPollSchema
13555
+ ]);
13536
13556
  ScheduleEntrySchema = exports_external.object({
13537
13557
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
13538
- prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
13539
- 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 " + "\u2014 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."),
13558
+ prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
13559
+ 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."),
13560
+ poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
13561
+ 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."),
13562
+ 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."),
13540
13563
  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."),
13541
13564
  topic: exports_external.union([
13542
13565
  exports_external.string().min(1, "topic alias must be non-empty"),
13543
13566
  exports_external.number().int().positive("topic ID must be a positive integer")
13544
13567
  ]).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 \u2014 typos surface immediately. " + "See docs/rfcs/supergroup-mode.md.")
13568
+ }).superRefine((entry, ctx) => {
13569
+ const kind = entry.kind ?? "prompt";
13570
+ if (kind === "poll" && !entry.poll) {
13571
+ ctx.addIssue({
13572
+ code: exports_external.ZodIssueCode.custom,
13573
+ path: ["poll"],
13574
+ message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
13575
+ });
13576
+ }
13577
+ if (kind === "prompt" && entry.poll) {
13578
+ ctx.addIssue({
13579
+ code: exports_external.ZodIssueCode.custom,
13580
+ path: ["poll"],
13581
+ message: "`poll` is only valid when kind: poll."
13582
+ });
13583
+ }
13545
13584
  });
13546
13585
  AgentSoulSchema = exports_external.object({
13547
13586
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -13988,6 +14027,13 @@ var init_schema = __esm(() => {
13988
14027
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 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."),
13989
14028
  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 \u00a75). 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.")
13990
14029
  });
14030
+ CronEgressSchema = exports_external.object({
14031
+ 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."),
14032
+ secret_bindings: exports_external.record(exports_external.string(), exports_external.string().min(1)).default({}).describe("secretName \u2192 the single host it may be sent to. A poll carrying a secret to any other host is rejected.")
14033
+ });
14034
+ CronConfigSchema = exports_external.object({
14035
+ egress: CronEgressSchema.optional().describe("SSRF/exfil fence for http-diff polls.")
14036
+ });
13991
14037
  SwitchroomConfigSchema = exports_external.object({
13992
14038
  switchroom: exports_external.object({
13993
14039
  version: exports_external.literal(1).describe("Config schema version"),
@@ -14036,7 +14082,8 @@ var init_schema = __esm(() => {
14036
14082
  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."),
14037
14083
  agents: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
14038
14084
  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)"
14039
- }), AgentSchema).describe("Map of agent name to agent configuration")
14085
+ }), AgentSchema).describe("Map of agent name to agent configuration"),
14086
+ 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 (\u00a76.1). Required to enable any http-diff poll; not agent-writable.")
14040
14087
  });
14041
14088
  });
14042
14089
 
@@ -15311,6 +15358,58 @@ var init_helpers = __esm(() => {
15311
15358
  init_loader();
15312
15359
  });
15313
15360
 
15361
+ // src/scheduler/cron-routing.ts
15362
+ function isKnownCheapModel(model) {
15363
+ return model !== undefined && CHEAP_MODEL_RE.test(model);
15364
+ }
15365
+ function resolveCronModel(model) {
15366
+ return isKnownCheapModel(model) ? model : DEFAULT_CRON_MODEL;
15367
+ }
15368
+ function resolveCronRouting(input, opts) {
15369
+ if (!opts.cheapCronEnabled) {
15370
+ return { tier: "main", session: "main", customModelDowngrade: false };
15371
+ }
15372
+ const kind = input.kind ?? "prompt";
15373
+ let context;
15374
+ let customModelDowngrade = false;
15375
+ if (input.context) {
15376
+ context = input.context;
15377
+ } else if (isKnownCheapModel(input.model)) {
15378
+ context = "fresh";
15379
+ } else {
15380
+ context = "agent";
15381
+ customModelDowngrade = input.model !== undefined && !isKnownCheapModel(input.model) && !OPUS_MODEL_RE.test(input.model);
15382
+ }
15383
+ if (kind === "poll") {
15384
+ return { tier: "poll", session: null, customModelDowngrade };
15385
+ }
15386
+ if (context === "fresh") {
15387
+ return {
15388
+ tier: "cheap",
15389
+ session: "cron",
15390
+ cronModel: resolveCronModel(input.model),
15391
+ customModelDowngrade
15392
+ };
15393
+ }
15394
+ return { tier: "main", session: "main", customModelDowngrade };
15395
+ }
15396
+ function resolveEscalationRouting(input, opts) {
15397
+ return resolveCronRouting({ ...input, kind: "prompt" }, opts);
15398
+ }
15399
+ function scheduleNeedsCronSession(entries, opts) {
15400
+ if (!opts.cheapCronEnabled)
15401
+ return false;
15402
+ return entries.some((e) => {
15403
+ const r = e.kind === "poll" ? resolveEscalationRouting(e, opts) : resolveCronRouting(e, opts);
15404
+ return r.session === "cron";
15405
+ });
15406
+ }
15407
+ var DEFAULT_CRON_MODEL = "claude-sonnet-4-6", CHEAP_MODEL_RE, OPUS_MODEL_RE;
15408
+ var init_cron_routing = __esm(() => {
15409
+ CHEAP_MODEL_RE = /(^|[^a-z])(sonnet|haiku)([^a-z]|$)/i;
15410
+ OPUS_MODEL_RE = /opus/i;
15411
+ });
15412
+
15314
15413
  // src/config/timezone.ts
15315
15414
  import { readFileSync as readFileSync3, readlinkSync } from "node:fs";
15316
15415
  function defaultReadEtcTimezone() {
@@ -23078,7 +23177,20 @@ var init_tmux = __esm(() => {
23078
23177
  import { createHash as createHash3 } from "node:crypto";
23079
23178
  import { existsSync as existsSync14, mkdirSync as mkdirSync10, readFileSync as readFileSync12 } from "node:fs";
23080
23179
  import { join as join9 } from "node:path";
23081
- function resolveResourceDefaults(agentName, profile, overrides) {
23180
+ function parseMemToMib(s) {
23181
+ const m = /^(\d+(?:\.\d+)?)\s*([gmk]?)b?$/i.exec(s.trim());
23182
+ if (!m)
23183
+ return null;
23184
+ const n = parseFloat(m[1]);
23185
+ const unit = (m[2] || "m").toLowerCase();
23186
+ const mult = unit === "g" ? 1024 : unit === "k" ? 1 / 1024 : 1;
23187
+ return Math.round(n * mult);
23188
+ }
23189
+ function bumpMem(s, addMib) {
23190
+ const mib = parseMemToMib(s);
23191
+ return mib == null ? s : `${mib + addMib}m`;
23192
+ }
23193
+ function resolveResourceDefaults(agentName, profile, overrides, opts) {
23082
23194
  let base;
23083
23195
  if (agentName === "klanker") {
23084
23196
  base = RESOURCE_BY_PROFILE.klanker;
@@ -23087,18 +23199,26 @@ function resolveResourceDefaults(agentName, profile, overrides) {
23087
23199
  } else {
23088
23200
  base = RESOURCE_BY_PROFILE.default;
23089
23201
  }
23090
- if (!overrides)
23091
- return { ...base };
23092
23202
  const merged = { ...base };
23093
- if (overrides.memory !== undefined)
23094
- merged.memLimit = overrides.memory;
23095
- if (overrides.cpus !== undefined)
23096
- merged.cpus = overrides.cpus;
23097
- if (overrides.memory_reservation !== undefined) {
23098
- merged.memReservation = overrides.memory_reservation;
23203
+ if (overrides) {
23204
+ if (overrides.memory !== undefined)
23205
+ merged.memLimit = overrides.memory;
23206
+ if (overrides.cpus !== undefined)
23207
+ merged.cpus = overrides.cpus;
23208
+ if (overrides.memory_reservation !== undefined) {
23209
+ merged.memReservation = overrides.memory_reservation;
23210
+ }
23211
+ if (overrides.pids_limit !== undefined) {
23212
+ merged.pidsLimit = overrides.pids_limit;
23213
+ }
23099
23214
  }
23100
- if (overrides.pids_limit !== undefined) {
23101
- merged.pidsLimit = overrides.pids_limit;
23215
+ if (opts?.cronSession) {
23216
+ if (overrides?.memory === undefined) {
23217
+ merged.memLimit = bumpMem(merged.memLimit, CRON_SESSION_MEM_BUMP_MIB);
23218
+ }
23219
+ if (overrides?.pids_limit === undefined && merged.pidsLimit !== undefined) {
23220
+ merged.pidsLimit = merged.pidsLimit + CRON_SESSION_PIDS_BUMP;
23221
+ }
23102
23222
  }
23103
23223
  return merged;
23104
23224
  }
@@ -23153,7 +23273,8 @@ function describeAgents(config) {
23153
23273
  const resolved = resolveAgentConfig(config.defaults, config.profiles, agent);
23154
23274
  const profile = agent.extends ?? "default";
23155
23275
  const uid = allocateAgentUid(name);
23156
- const resources = resolveResourceDefaults(name, profile, resolved.resources);
23276
+ const cronSession = scheduleNeedsCronSession((resolved.schedule ?? []).map((e) => ({ kind: e.kind, model: e.model, context: e.context })), { cheapCronEnabled: true });
23277
+ const resources = resolveResourceDefaults(name, profile, resolved.resources, { cronSession });
23157
23278
  const strippedCaps = readStrippedCaps(agent);
23158
23279
  out.push({
23159
23280
  name,
@@ -23616,8 +23737,9 @@ function emitAgentService(lines, a, imageTag, buildMode, buildContext, homePrefi
23616
23737
  lines.push(` - ${homePrefix}/.switchroom/logs/${a.name}:${homePrefix}/.switchroom/logs/${a.name}`);
23617
23738
  lines.push(``);
23618
23739
  }
23619
- var AGENT_UID_MIN = 10001, AGENT_UID_MAX = 10999, RESOURCE_BY_PROFILE, BIND_MOUNT_SOURCE_DENYLIST, BIND_MOUNT_TARGET_DENYLIST, BIND_MOUNT_EXACT_SOURCE_DENY;
23740
+ var AGENT_UID_MIN = 10001, AGENT_UID_MAX = 10999, RESOURCE_BY_PROFILE, CRON_SESSION_MEM_BUMP_MIB = 512, CRON_SESSION_PIDS_BUMP = 128, BIND_MOUNT_SOURCE_DENYLIST, BIND_MOUNT_TARGET_DENYLIST, BIND_MOUNT_EXACT_SOURCE_DENY;
23620
23741
  var init_compose = __esm(() => {
23742
+ init_cron_routing();
23621
23743
  init_merge();
23622
23744
  init_timezone();
23623
23745
  init_peercred();
@@ -49815,8 +49937,8 @@ var {
49815
49937
  } = import__.default;
49816
49938
 
49817
49939
  // src/build-info.ts
49818
- var VERSION = "0.14.91";
49819
- var COMMIT_SHA = "e938daab";
49940
+ var VERSION = "0.14.92";
49941
+ var COMMIT_SHA = "cd0b9973";
49820
49942
 
49821
49943
  // src/cli/agent.ts
49822
49944
  init_source();
@@ -49926,6 +50048,7 @@ var LEGACY_CRON_SCRIPT_BASENAME_RE = /^cron-(\d+)\.sh$/;
49926
50048
 
49927
50049
  // src/agents/scaffold.ts
49928
50050
  init_schema();
50051
+ init_cron_routing();
49929
50052
  init_merge();
49930
50053
  init_timezone();
49931
50054
 
@@ -50808,6 +50931,33 @@ function renderFleetInvariants() {
50808
50931
  ].join(`
50809
50932
  `);
50810
50933
  }
50934
+ function buildCronSessionContext(agentConfig) {
50935
+ const entries = (agentConfig.schedule ?? []).map((e) => ({
50936
+ kind: e.kind,
50937
+ model: e.model,
50938
+ context: e.context
50939
+ }));
50940
+ return {
50941
+ cronSessionEnabled: scheduleNeedsCronSession(entries, { cheapCronEnabled: true }),
50942
+ cronModelQ: shellSingleQuote(DEFAULT_CRON_MODEL)
50943
+ };
50944
+ }
50945
+ function maybeWriteTrimmedCronMcp(agentDir, mcpServers, cronSessionEnabled) {
50946
+ if (!cronSessionEnabled)
50947
+ return null;
50948
+ const telegram = mcpServers["switchroom-telegram"];
50949
+ if (!telegram)
50950
+ return null;
50951
+ const cronDir = join8(agentDir, ".claude-cron");
50952
+ mkdirSync9(cronDir, { recursive: true });
50953
+ const path = join8(cronDir, ".mcp.json");
50954
+ const content = JSON.stringify({ mcpServers: { "switchroom-telegram": telegram } }, null, 2) + `
50955
+ `;
50956
+ if (!existsSync13(path) || readFileSync11(path, "utf-8") !== content) {
50957
+ writeFileSync5(path, content, { encoding: "utf-8", mode: 384 });
50958
+ }
50959
+ return path;
50960
+ }
50811
50961
  function alignAgentUid(name, agentDir, uid, opts = {}) {
50812
50962
  const writeOut = opts.writeOut ?? ((s) => process.stdout.write(s));
50813
50963
  const logsDir = join8(homedir4(), ".switchroom", "logs", name);
@@ -51617,6 +51767,7 @@ function buildWorkspaceContext(args) {
51617
51767
  switchroomConfigPathQ: switchroomConfigPath ? shellSingleQuote(resolve11(switchroomConfigPath)) : undefined,
51618
51768
  hostHomeQ: process.env.HOME ? shellSingleQuote(process.env.HOME) : undefined,
51619
51769
  modelQ: shellSingleQuote(agentConfig.model ?? SWITCHROOM_DEFAULT_MAIN_MODEL),
51770
+ ...buildCronSessionContext(agentConfig),
51620
51771
  thinkingEffort: agentConfig.thinking_effort ?? SWITCHROOM_DEFAULT_THINKING_EFFORT,
51621
51772
  permissionMode: agentConfig.permission_mode,
51622
51773
  fallbackModelQ: agentConfig.fallback_model ? shellSingleQuote(agentConfig.fallback_model) : undefined,
@@ -51856,6 +52007,12 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
51856
52007
  if (existsSync13(join8(agentDir, "start.sh"))) {
51857
52008
  chmodSync2(join8(agentDir, "start.sh"), 448);
51858
52009
  }
52010
+ if (context.cronSessionEnabled) {
52011
+ writeIfChanged(join8(agentDir, "cron-session.sh"), () => renderTemplate(join8(basePath, "cron-session.sh.hbs"), context), created, skipped);
52012
+ if (existsSync13(join8(agentDir, "cron-session.sh"))) {
52013
+ chmodSync2(join8(agentDir, "cron-session.sh"), 448);
52014
+ }
52015
+ }
51859
52016
  writeIfMissing(join8(agentDir, ".claude", "settings.json"), () => renderTemplate(join8(basePath, "settings.json.hbs"), context), created, skipped, 384);
51860
52017
  if (switchroomConfig) {
51861
52018
  const settingsPath = join8(agentDir, ".claude", "settings.json");
@@ -51980,6 +52137,7 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
51980
52137
  }
51981
52138
  writeIfChanged(mcpJsonPath, () => JSON.stringify({ mcpServers }, null, 2) + `
51982
52139
  `, created, skipped, 384);
52140
+ maybeWriteTrimmedCronMcp(agentDir, mcpServers, buildCronSessionContext(agentConfig).cronSessionEnabled);
51983
52141
  mcpServerKeysToTrust = Object.keys(mcpServers);
51984
52142
  ensureMcpServersTrusted(agentDir, mcpServerKeysToTrust);
51985
52143
  }
@@ -52683,6 +52841,7 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
52683
52841
  hindsightTopicFilterMode,
52684
52842
  hostHomeQ: process.env.HOME ? shellSingleQuote(process.env.HOME) : undefined,
52685
52843
  modelQ: shellSingleQuote(agentConfig.model ?? SWITCHROOM_DEFAULT_MAIN_MODEL),
52844
+ ...buildCronSessionContext(agentConfig),
52686
52845
  thinkingEffort: agentConfig.thinking_effort ?? SWITCHROOM_DEFAULT_THINKING_EFFORT,
52687
52846
  permissionMode: agentConfig.permission_mode,
52688
52847
  fallbackModelQ: agentConfig.fallback_model ? shellSingleQuote(agentConfig.fallback_model) : undefined,
@@ -52737,6 +52896,16 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
52737
52896
  chmodSync2(startShPath, 493);
52738
52897
  changes.push(startShPath);
52739
52898
  }
52899
+ if (startShContext.cronSessionEnabled) {
52900
+ const cronShPath = join8(agentDir, "cron-session.sh");
52901
+ const beforeCronSh = existsSync13(cronShPath) ? readFileSync11(cronShPath, "utf-8") : "";
52902
+ const afterCronSh = renderTemplate(join8(basePath, "cron-session.sh.hbs"), startShContext);
52903
+ if (afterCronSh !== beforeCronSh) {
52904
+ writeFileSync5(cronShPath, afterCronSh, "utf-8");
52905
+ chmodSync2(cronShPath, 493);
52906
+ changes.push(cronShPath);
52907
+ }
52908
+ }
52740
52909
  }
52741
52910
  if (!options.preserveClaudeMd && !options.skipProfileTemplates) {
52742
52911
  const profilePath = getProfilePath(agentConfig.extends ?? DEFAULT_PROFILE);
@@ -53024,6 +53193,9 @@ ${body}
53024
53193
  writeFileSync5(mcpJsonPath, after, { encoding: "utf-8", mode: 384 });
53025
53194
  changes.push(mcpJsonPath);
53026
53195
  }
53196
+ const trimmedCronMcp = maybeWriteTrimmedCronMcp(agentDir, mcpServers, buildCronSessionContext(agentConfig).cronSessionEnabled);
53197
+ if (trimmedCronMcp)
53198
+ changes.push(trimmedCronMcp);
53027
53199
  ensureMcpServersTrusted(agentDir, Object.keys(mcpServers));
53028
53200
  }
53029
53201
  const reconcileWorkspaceDir = join8(agentDir, "workspace");
@@ -67170,7 +67342,11 @@ function collectScheduleEntries(config) {
67170
67342
  cron: entry.cron,
67171
67343
  prompt: entry.prompt,
67172
67344
  promptKey: createHash9("sha256").update(entry.prompt).digest("hex").slice(0, 12),
67173
- ...entry.topic !== undefined ? { topic: entry.topic } : {}
67345
+ ...entry.topic !== undefined ? { topic: entry.topic } : {},
67346
+ ...entry.kind !== undefined ? { kind: entry.kind } : {},
67347
+ ...entry.model !== undefined ? { model: entry.model } : {},
67348
+ ...entry.context !== undefined ? { context: entry.context } : {},
67349
+ ...entry.poll !== undefined ? { poll: entry.poll } : {}
67174
67350
  });
67175
67351
  }
67176
67352
  }
@@ -81965,8 +82141,80 @@ function denyPendingScheduleEntry(opts) {
81965
82141
  }
81966
82142
 
81967
82143
  // src/cli/agent-config-write.ts
81968
- init_protocol3();
81969
82144
  import { existsSync as existsSync78, readFileSync as readFileSync65 } from "node:fs";
82145
+ import { execFileSync as execFileSync25 } from "node:child_process";
82146
+
82147
+ // src/scheduler/schedule-report.ts
82148
+ var TIER_WEIGHT = { poll: 0, cheap: 1, main: 5 };
82149
+ function summarizeScheduleReport(rows, opts = {}) {
82150
+ const s = {
82151
+ total: 0,
82152
+ pollFires: 0,
82153
+ cheapFires: 0,
82154
+ mainFires: 0,
82155
+ errors: 0,
82156
+ deferred: 0,
82157
+ costWeight: 0,
82158
+ byModel: {}
82159
+ };
82160
+ for (const r of rows) {
82161
+ if (opts.sinceMs !== undefined && (r.startedAt ?? 0) < opts.sinceMs)
82162
+ continue;
82163
+ s.total += 1;
82164
+ const tier = r.tier ?? "main";
82165
+ if (tier === "poll")
82166
+ s.pollFires += 1;
82167
+ else if (tier === "cheap")
82168
+ s.cheapFires += 1;
82169
+ else
82170
+ s.mainFires += 1;
82171
+ s.costWeight += TIER_WEIGHT[tier];
82172
+ if (r.exitCode === -2)
82173
+ s.deferred += 1;
82174
+ else if (r.exitCode < 0)
82175
+ s.errors += 1;
82176
+ if (r.modelUsed)
82177
+ s.byModel[r.modelUsed] = (s.byModel[r.modelUsed] ?? 0) + 1;
82178
+ }
82179
+ return s;
82180
+ }
82181
+ function parseScheduleJsonl(blob) {
82182
+ const rows = [];
82183
+ for (const line of blob.split(`
82184
+ `)) {
82185
+ const t = line.trim();
82186
+ if (!t)
82187
+ continue;
82188
+ try {
82189
+ const o = JSON.parse(t);
82190
+ if (typeof o.exitCode === "number")
82191
+ rows.push(o);
82192
+ } catch {}
82193
+ }
82194
+ return rows;
82195
+ }
82196
+ function formatScheduleReport(agent, s) {
82197
+ const modelFires = s.cheapFires + s.mainFires;
82198
+ const savedPct = s.total > 0 ? Math.round(s.pollFires / s.total * 100) : 0;
82199
+ const lines = [
82200
+ `cron report \u2014 ${agent}`,
82201
+ ` total fires ${s.total}`,
82202
+ ` Tier 0 poll (free) ${s.pollFires} (${savedPct}% model-free)`,
82203
+ ` Tier 1 cheap ${s.cheapFires}`,
82204
+ ` Tier 2 main ${s.mainFires}`,
82205
+ ` model fires ${modelFires}`,
82206
+ ` errors / deferred ${s.errors} / ${s.deferred}`,
82207
+ ` cost-weight proxy ${s.costWeight} (poll\u00d70 + cheap\u00d71 + main\u00d75; relative, not $)`
82208
+ ];
82209
+ const models = Object.entries(s.byModel);
82210
+ if (models.length)
82211
+ lines.push(` by model ${models.map(([m, n]) => `${m}=${n}`).join(" ")}`);
82212
+ return lines.join(`
82213
+ `);
82214
+ }
82215
+
82216
+ // src/cli/agent-config-write.ts
82217
+ init_protocol3();
81970
82218
  var MAX_ENTRIES_PER_AGENT = 20;
81971
82219
  var MIN_CRON_INTERVAL_MIN = 5;
81972
82220
  function extractCronSmallestGapMin(expr) {
@@ -82441,6 +82689,43 @@ function registerAgentConfigWriteCommands(program3) {
82441
82689
  `);
82442
82690
  appendAudit(resolvedAgent, "schedule.remove", { ...opts, slug: r.slug }, 0);
82443
82691
  });
82692
+ schedule.command("report [agent]").description("Cheap-cron cost breakdown from the agent's scheduler.jsonl (Tier 0/1/2 fire counts)").option("--jsonl <path>", "Read a local scheduler.jsonl instead of docker-exec into the container").option("--since <iso>", "Only count fires at/after this ISO timestamp").option("--json", "Emit the summary as JSON").action((agentArg, opts) => {
82693
+ const agent = agentArg ?? process.env.SWITCHROOM_AGENT_NAME;
82694
+ if (!agent) {
82695
+ process.stderr.write(`schedule report: pass an agent name (or set $SWITCHROOM_AGENT_NAME)
82696
+ `);
82697
+ process.exit(2);
82698
+ }
82699
+ let blob;
82700
+ if (opts.jsonl) {
82701
+ blob = existsSync78(opts.jsonl) ? readFileSync65(opts.jsonl, "utf-8") : "";
82702
+ } else {
82703
+ try {
82704
+ blob = execFileSync25("docker", ["exec", `switchroom-${agent}`, "cat", "/state/agent/scheduler.jsonl"], {
82705
+ encoding: "utf-8",
82706
+ stdio: ["ignore", "pipe", "ignore"]
82707
+ });
82708
+ } catch {
82709
+ process.stderr.write(`schedule report: could not read scheduler.jsonl from container switchroom-${agent} ` + `(is it running? try --jsonl <path>)
82710
+ `);
82711
+ process.exit(1);
82712
+ }
82713
+ }
82714
+ const sinceMs = opts.since ? Date.parse(opts.since) : undefined;
82715
+ if (opts.since && Number.isNaN(sinceMs)) {
82716
+ process.stderr.write(`schedule report: invalid --since '${opts.since}'
82717
+ `);
82718
+ process.exit(2);
82719
+ }
82720
+ const summary = summarizeScheduleReport(parseScheduleJsonl(blob), sinceMs ? { sinceMs } : {});
82721
+ if (opts.json) {
82722
+ process.stdout.write(JSON.stringify({ agent, ...summary }) + `
82723
+ `);
82724
+ } else {
82725
+ process.stdout.write(formatScheduleReport(agent, summary) + `
82726
+ `);
82727
+ }
82728
+ });
82444
82729
  }
82445
82730
 
82446
82731
  // src/cli/agent-config-skill-write.ts
@@ -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