switchroom 0.15.13 → 0.15.15

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.
@@ -10989,11 +10989,29 @@ var PollSpecSchema = exports_external.discriminatedUnion("type", [
10989
10989
  HttpDiffPollSchema,
10990
10990
  TelegramReactionsPollSchema
10991
10991
  ]);
10992
+ var TelegramMessageActionSchema = exports_external.object({
10993
+ type: exports_external.literal("telegram-message"),
10994
+ 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."),
10995
+ parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
10996
+ });
10997
+ var WebhookActionSchema = exports_external.object({
10998
+ type: exports_external.literal("webhook"),
10999
+ 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."),
11000
+ method: exports_external.enum(["GET", "POST"]).default("POST"),
11001
+ headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
11002
+ body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
11003
+ 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.")
11004
+ });
11005
+ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
11006
+ TelegramMessageActionSchema,
11007
+ WebhookActionSchema
11008
+ ]);
10992
11009
  var ScheduleEntrySchema = exports_external.object({
10993
11010
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
10994
- prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
10995
- 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."),
11011
+ 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)."),
11012
+ 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)."),
10996
11013
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11014
+ action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
10997
11015
  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."),
10998
11016
  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."),
10999
11017
  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."),
@@ -11010,13 +11028,41 @@ var ScheduleEntrySchema = exports_external.object({
11010
11028
  message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11011
11029
  });
11012
11030
  }
11013
- if (kind === "prompt" && entry.poll) {
11031
+ if (kind !== "poll" && entry.poll) {
11014
11032
  ctx.addIssue({
11015
11033
  code: exports_external.ZodIssueCode.custom,
11016
11034
  path: ["poll"],
11017
11035
  message: "`poll` is only valid when kind: poll."
11018
11036
  });
11019
11037
  }
11038
+ if (kind === "action" && !entry.action) {
11039
+ ctx.addIssue({
11040
+ code: exports_external.ZodIssueCode.custom,
11041
+ path: ["action"],
11042
+ message: "kind: action requires an `action` spec (telegram-message or webhook)."
11043
+ });
11044
+ }
11045
+ if (kind !== "action" && entry.action) {
11046
+ ctx.addIssue({
11047
+ code: exports_external.ZodIssueCode.custom,
11048
+ path: ["action"],
11049
+ message: "`action` is only valid when kind: action."
11050
+ });
11051
+ }
11052
+ if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
11053
+ ctx.addIssue({
11054
+ code: exports_external.ZodIssueCode.custom,
11055
+ path: ["prompt"],
11056
+ message: `kind: ${kind} requires a non-empty \`prompt\`.`
11057
+ });
11058
+ }
11059
+ if (kind === "action" && entry.prompt !== undefined) {
11060
+ ctx.addIssue({
11061
+ code: exports_external.ZodIssueCode.custom,
11062
+ path: ["prompt"],
11063
+ message: "`prompt` is not valid for kind: action (an action never fires a model)."
11064
+ });
11065
+ }
11020
11066
  });
11021
11067
  var AgentSoulSchema = exports_external.object({
11022
11068
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -12244,17 +12290,19 @@ function collectScheduleEntries(config) {
12244
12290
  const schedule = resolved.schedule ?? [];
12245
12291
  for (let i = 0;i < schedule.length; i++) {
12246
12292
  const entry = schedule[i];
12293
+ const auditMaterial = entry.prompt ?? `action:${JSON.stringify(entry.action ?? {})}`;
12247
12294
  out.push({
12248
12295
  agent,
12249
12296
  scheduleIndex: i,
12250
12297
  cron: entry.cron,
12251
- prompt: entry.prompt,
12252
- promptKey: createHash("sha256").update(entry.prompt).digest("hex").slice(0, 12),
12298
+ ...entry.prompt !== undefined ? { prompt: entry.prompt } : {},
12299
+ promptKey: createHash("sha256").update(auditMaterial).digest("hex").slice(0, 12),
12253
12300
  ...entry.topic !== undefined ? { topic: entry.topic } : {},
12254
12301
  ...entry.kind !== undefined ? { kind: entry.kind } : {},
12255
12302
  ...entry.model !== undefined ? { model: entry.model } : {},
12256
12303
  ...entry.context !== undefined ? { context: entry.context } : {},
12257
- ...entry.poll !== undefined ? { poll: entry.poll } : {}
12304
+ ...entry.poll !== undefined ? { poll: entry.poll } : {},
12305
+ ...entry.action !== undefined ? { action: entry.action } : {}
12258
12306
  });
12259
12307
  }
12260
12308
  }
@@ -12270,7 +12318,7 @@ function dispatchAsInbound(entry, options, dispatcher) {
12270
12318
  user: "cron",
12271
12319
  userId: 0,
12272
12320
  ts,
12273
- text: entry.prompt,
12321
+ text: entry.prompt ?? "",
12274
12322
  meta: {
12275
12323
  source: "cron",
12276
12324
  schedule_index: String(entry.scheduleIndex),
@@ -12298,6 +12346,9 @@ function resolveCronModel(model) {
12298
12346
  return isKnownCheapModel(model) ? model : DEFAULT_CRON_MODEL;
12299
12347
  }
12300
12348
  function resolveCronRouting(input, opts) {
12349
+ if ((input.kind ?? "prompt") === "action") {
12350
+ return { tier: "action", session: null, customModelDowngrade: false };
12351
+ }
12301
12352
  if (!opts.cheapCronEnabled) {
12302
12353
  return { tier: "main", session: "main", customModelDowngrade: false };
12303
12354
  }
@@ -12329,10 +12380,97 @@ function resolveEscalationRouting(input, opts) {
12329
12380
  return resolveCronRouting({ ...input, kind: "prompt" }, opts);
12330
12381
  }
12331
12382
 
12383
+ // src/scheduler/cron-cadence.ts
12384
+ function csvSmallestGap(field) {
12385
+ if (!field.includes(","))
12386
+ return null;
12387
+ const parts = field.split(",").map((s) => Number(s)).filter((n) => Number.isInteger(n) && n >= 0);
12388
+ if (parts.length < 2)
12389
+ return null;
12390
+ const sorted = [...parts].sort((a, b) => a - b);
12391
+ let smallest = Infinity;
12392
+ for (let i = 1;i < sorted.length; i++) {
12393
+ const gap = sorted[i] - sorted[i - 1];
12394
+ if (gap > 0 && gap < smallest)
12395
+ smallest = gap;
12396
+ }
12397
+ return Number.isFinite(smallest) ? smallest : null;
12398
+ }
12399
+ function estimateCronGapMin(expr) {
12400
+ const fields = expr.trim().split(/\s+/);
12401
+ if (fields.length < 5)
12402
+ return Infinity;
12403
+ const [min, hour] = fields;
12404
+ if (min === "*")
12405
+ return 1;
12406
+ const minStep = min.match(/^\*\/(\d+)$/);
12407
+ if (minStep) {
12408
+ const n = Number(minStep[1]);
12409
+ return n > 0 ? n : Infinity;
12410
+ }
12411
+ const minCsv = csvSmallestGap(min);
12412
+ if (minCsv !== null)
12413
+ return minCsv;
12414
+ if (!/^\d+$/.test(min))
12415
+ return Infinity;
12416
+ if (hour === "*")
12417
+ return 60;
12418
+ const hourStep = hour.match(/^\*\/(\d+)$/);
12419
+ if (hourStep) {
12420
+ const n = Number(hourStep[1]);
12421
+ return n > 0 ? n * 60 : Infinity;
12422
+ }
12423
+ const hourCsv = csvSmallestGap(hour);
12424
+ if (hourCsv !== null)
12425
+ return hourCsv * 60;
12426
+ if (/^\d+$/.test(hour))
12427
+ return 1440;
12428
+ return Infinity;
12429
+ }
12430
+
12332
12431
  // src/scheduler/tier-selector.ts
12333
12432
  var DEFAULT_FREQUENT_GAP_MIN = 60;
12334
- function applyDefaultTier(entry, _frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
12335
- return entry;
12433
+ function resolveCronAutoTier(env = process.env) {
12434
+ const v = (env.SWITCHROOM_CRON_AUTO_TIER ?? "").toLowerCase();
12435
+ return v === "1" || v === "true" || v === "on";
12436
+ }
12437
+ function recommendCronTier(input, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
12438
+ if (input.kind === "action") {
12439
+ return { tier: "action", source: "explicit", reason: "declared kind: action (model-free verb)" };
12440
+ }
12441
+ if (input.kind === "poll") {
12442
+ return { tier: "poll", source: "explicit", reason: "declared kind: poll (model-free check)" };
12443
+ }
12444
+ if (input.context === "fresh") {
12445
+ return { tier: "cheap", source: "explicit", reason: "declared context: fresh (cheap cron session)" };
12446
+ }
12447
+ if (input.context === "agent") {
12448
+ return { tier: "main", source: "explicit", reason: "declared context: agent (full live session)" };
12449
+ }
12450
+ if (input.model !== undefined) {
12451
+ return isKnownCheapModel(input.model) ? { tier: "cheap", source: "explicit", reason: `cheap model '${input.model}' → cheap cron session` } : { tier: "main", source: "explicit", reason: `model '${input.model}' is not a known-cheap id → full live session` };
12452
+ }
12453
+ if (input.smallestGapMin <= frequentGapMin) {
12454
+ return {
12455
+ tier: "cheap",
12456
+ source: "cadence-default",
12457
+ reason: `fires every ~${input.smallestGapMin}min (≤ ${frequentGapMin}min) — defaulting to a cheap ` + `session; set context: agent (or an Opus/custom model) if this needs the agent's full context`
12458
+ };
12459
+ }
12460
+ return {
12461
+ tier: "main",
12462
+ source: "cadence-default",
12463
+ reason: `fires every ~${input.smallestGapMin}min (> ${frequentGapMin}min) — defaulting to the agent's ` + `full session; set model: sonnet (or context: fresh) to run it cheaply`
12464
+ };
12465
+ }
12466
+ function applyDefaultTier(entry, frequentGapMin = DEFAULT_FREQUENT_GAP_MIN, autoTierEnabled = resolveCronAutoTier()) {
12467
+ if (!autoTierEnabled)
12468
+ return entry;
12469
+ if (entry.kind !== undefined || entry.context !== undefined || entry.model !== undefined) {
12470
+ return entry;
12471
+ }
12472
+ const rec = recommendCronTier({ smallestGapMin: estimateCronGapMin(entry.cron) }, frequentGapMin);
12473
+ return rec.tier === "cheap" ? { ...entry, context: "fresh" } : entry;
12336
12474
  }
12337
12475
 
12338
12476
  // src/agent-scheduler/cheap-cron-wiring.ts
@@ -12549,6 +12687,99 @@ async function readCapped(res, maxBytes) {
12549
12687
  return new TextDecoder().decode(buf);
12550
12688
  }
12551
12689
 
12690
+ // src/scheduler/action-engine.ts
12691
+ var PLACEHOLDER_RE = /\{\{\s*(date|time|agent)\s*\}\}/g;
12692
+ function substituteDeterministic(text, ctx) {
12693
+ const d = new Date(ctx.now);
12694
+ const date = d.toISOString().slice(0, 10);
12695
+ const time = d.toISOString().slice(11, 16);
12696
+ return text.replace(PLACEHOLDER_RE, (_, name) => {
12697
+ if (name === "date")
12698
+ return date;
12699
+ if (name === "time")
12700
+ return time;
12701
+ return ctx.agent;
12702
+ });
12703
+ }
12704
+ function substituteSecrets2(s, secretValues) {
12705
+ return s.replace(/\{\{([a-zA-Z0-9_\-/]+)\}\}/g, (_, name) => secretValues[name] ?? "");
12706
+ }
12707
+ async function runTelegramMessage(spec, deps) {
12708
+ const text = substituteDeterministic(spec.text, { now: deps.now(), agent: deps.agent });
12709
+ let delivered;
12710
+ try {
12711
+ delivered = await deps.sendMessage(text, { parseMode: spec.parse_mode });
12712
+ } catch (e) {
12713
+ return { ok: false, summary: "", error: `send failed: ${e.message}` };
12714
+ }
12715
+ return delivered ? { ok: true, summary: `message sent (${text.length} chars)` } : { ok: false, summary: "", error: "message not delivered (no gateway / send rejected)" };
12716
+ }
12717
+ async function runWebhook(spec, deps) {
12718
+ const gate = checkEgressUrl(spec.url, deps.allow);
12719
+ if (!gate.ok)
12720
+ return { ok: false, summary: "", error: `egress denied: ${gate.reason}` };
12721
+ const host = normalizeHost(new URL(spec.url).hostname);
12722
+ for (const s of spec.secrets) {
12723
+ const sc = checkSecretBinding(s, host, deps.allow);
12724
+ if (!sc.ok)
12725
+ return { ok: false, summary: "", error: `egress denied: ${sc.reason}` };
12726
+ }
12727
+ try {
12728
+ const ip = await deps.lookup(host);
12729
+ if (ip && isBlockedAddress(ip))
12730
+ return { ok: false, summary: "", error: `resolved ip ${ip} blocked` };
12731
+ } catch (e) {
12732
+ return { ok: false, summary: "", error: `dns lookup failed: ${e.message}` };
12733
+ }
12734
+ const secretValues = {};
12735
+ for (const name of spec.secrets) {
12736
+ try {
12737
+ secretValues[name] = await deps.resolveSecret(name);
12738
+ } catch (e) {
12739
+ return { ok: false, summary: "", error: `secret ${name}: ${e.message}` };
12740
+ }
12741
+ }
12742
+ const headers = {};
12743
+ for (const [k, v] of Object.entries(spec.headers ?? {})) {
12744
+ headers[k] = substituteSecrets2(v, secretValues);
12745
+ }
12746
+ const body = spec.body !== undefined ? substituteSecrets2(spec.body, secretValues) : undefined;
12747
+ const controller = new AbortController;
12748
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? 5000);
12749
+ try {
12750
+ const res = await deps.fetchImpl(spec.url, {
12751
+ method: spec.method,
12752
+ headers,
12753
+ ...body !== undefined ? { body } : {},
12754
+ redirect: "error",
12755
+ signal: controller.signal
12756
+ });
12757
+ const buf = await res.arrayBuffer();
12758
+ if (buf.byteLength > (deps.maxBytes ?? 1024 * 1024)) {
12759
+ return { ok: false, summary: "", error: `response ${buf.byteLength}B exceeds cap` };
12760
+ }
12761
+ if (!res.ok)
12762
+ return { ok: false, summary: "", error: `http ${res.status}` };
12763
+ return { ok: true, summary: `webhook ${spec.method} ${host} → ${res.status}` };
12764
+ } catch (e) {
12765
+ return { ok: false, summary: "", error: `fetch failed: ${e.message}` };
12766
+ } finally {
12767
+ clearTimeout(timer);
12768
+ }
12769
+ }
12770
+ async function runAction(spec, deps) {
12771
+ switch (spec.type) {
12772
+ case "telegram-message":
12773
+ return runTelegramMessage(spec, deps);
12774
+ case "webhook":
12775
+ return runWebhook(spec, deps);
12776
+ default: {
12777
+ const _never = spec;
12778
+ return { ok: false, summary: "", error: `unknown action type: ${JSON.stringify(_never)}` };
12779
+ }
12780
+ }
12781
+ }
12782
+
12552
12783
  // src/scheduler/poll-state.ts
12553
12784
  import { mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
12554
12785
  import { dirname } from "node:path";
@@ -13072,6 +13303,8 @@ function buildCheapCronHooks(config, env, deps = {}) {
13072
13303
  const fetchImpl = deps.fetchImpl ?? fetch;
13073
13304
  const lookup = deps.lookup ?? defaultLookup;
13074
13305
  const now = deps.now ?? Date.now;
13306
+ const agentName = deps.agentName ?? env.SWITCHROOM_AGENT_NAME ?? "-";
13307
+ const postOutbound = deps.postOutbound;
13075
13308
  const runPoll = async (spec, prevCursor) => {
13076
13309
  if (spec.type === "http-diff") {
13077
13310
  return runHttpDiffPoll(spec, prevCursor, {
@@ -13084,7 +13317,18 @@ function buildCheapCronHooks(config, env, deps = {}) {
13084
13317
  }
13085
13318
  return { hit: false, baseline: false, error: `poll type '${spec.type}' not yet wired (staged)` };
13086
13319
  };
13087
- return { enabled: true, pollState, runPoll };
13320
+ const runAction2 = async (spec, ctx) => {
13321
+ return runAction(spec, {
13322
+ fetchImpl,
13323
+ resolveSecret,
13324
+ lookup,
13325
+ allow: egress,
13326
+ now,
13327
+ agent: agentName,
13328
+ sendMessage: async (text, opts) => postOutbound ? postOutbound({ threadId: ctx.threadId, text, parseMode: opts.parseMode }) : false
13329
+ });
13330
+ };
13331
+ return { enabled: true, pollState, runPoll, runAction: runAction2 };
13088
13332
  }
13089
13333
 
13090
13334
  // src/telegram/topic-router.ts
@@ -13876,6 +14120,17 @@ function createInjectIpcClient(options) {
13876
14120
  return false;
13877
14121
  try {
13878
14122
  return socket.write(JSON.stringify(msg) + `
14123
+ `);
14124
+ } catch (err) {
14125
+ log(`scheduler ipc: write failed: ${err.message}`);
14126
+ return false;
14127
+ }
14128
+ },
14129
+ sendOutbound(msg) {
14130
+ if (!socket || !connected)
14131
+ return false;
14132
+ try {
14133
+ return socket.write(JSON.stringify(msg) + `
13879
14134
  `);
13880
14135
  } catch (err) {
13881
14136
  log(`scheduler ipc: write failed: ${err.message}`);
@@ -14229,7 +14484,7 @@ function readRecentFires(jsonlPath) {
14229
14484
 
14230
14485
  // src/agent-scheduler/index.ts
14231
14486
  function templateEscalationPrompt(prompt, diff) {
14232
- return prompt.replace(/\{\{\s*diff\s*\}\}/g, diff);
14487
+ return (prompt ?? "").replace(/\{\{\s*diff\s*\}\}/g, diff);
14233
14488
  }
14234
14489
  function recoverPendingEscalations(opts) {
14235
14490
  let recovered = 0;
@@ -14374,6 +14629,28 @@ function registerAgentSchedule(opts) {
14374
14629
  });
14375
14630
  return;
14376
14631
  }
14632
+ if (routing.tier === "action") {
14633
+ if (!opts.cheapCron?.runAction || !entry.action) {
14634
+ record({
14635
+ exitCode: -4,
14636
+ summary: !entry.action ? "action skipped — no action spec on entry" : "action skipped — action transport unavailable",
14637
+ tier: "action"
14638
+ });
14639
+ return;
14640
+ }
14641
+ let outcome;
14642
+ try {
14643
+ outcome = await opts.cheapCron.runAction(entry.action, { threadId });
14644
+ } catch (err) {
14645
+ outcome = { ok: false, summary: "", error: err.message };
14646
+ }
14647
+ record({
14648
+ exitCode: outcome.ok ? 0 : -4,
14649
+ summary: outcome.ok ? `action ok: ${outcome.summary}` : `action error: ${outcome.error}`,
14650
+ tier: "action"
14651
+ });
14652
+ return;
14653
+ }
14377
14654
  let delivered = false;
14378
14655
  let summary = "";
14379
14656
  try {
@@ -14547,6 +14824,19 @@ async function main() {
14547
14824
  const cheapEnabledForReplay = isCheapCronEnabled(process.env);
14548
14825
  for (const m of missed) {
14549
14826
  const startedAt = Date.now();
14827
+ if (m.entry.kind === "action") {
14828
+ sink.recordFire({
14829
+ agent: m.entry.agent,
14830
+ scheduleIndex: m.entry.scheduleIndex,
14831
+ promptKey: m.entry.promptKey,
14832
+ exitCode: 0,
14833
+ outputSummary: "action replay skipped — runs on next tick (never double-fired)",
14834
+ startedAt,
14835
+ finishedAt: Date.now(),
14836
+ tier: "action"
14837
+ });
14838
+ continue;
14839
+ }
14550
14840
  if (cheapEnabledForReplay && m.entry.kind === "poll") {
14551
14841
  sink.recordFire({
14552
14842
  agent: m.entry.agent,
@@ -14577,7 +14867,8 @@ async function main() {
14577
14867
  process.stdout.write(`agent-scheduler: ${staleSkipped.length} scheduled run(s) ` + `skipped (older than ${windowMinutes}min window) — notifying user
14578
14868
  `);
14579
14869
  const lines = staleSkipped.map((s) => {
14580
- const oneLine = s.entry.prompt.replace(/\s+/g, " ").trim().slice(0, 80);
14870
+ const label = s.entry.prompt ?? `action: ${s.entry.action?.type ?? "?"}`;
14871
+ const oneLine = label.replace(/\s+/g, " ").trim().slice(0, 80);
14581
14872
  return `- "${oneLine}" — cron \`${s.entry.cron}\`, ` + `most recent missed run ~${new Date(s.expectedFireMs).toISOString()}`;
14582
14873
  });
14583
14874
  const noticeText = "[switchroom scheduler notice] While this agent was offline, the " + "following scheduled task(s) had at least one run skipped. They were " + `older than the ${windowMinutes}-minute catch-up window, so they ` + `will NOT be re-run:
@@ -14633,7 +14924,15 @@ Briefly and plainly tell the user these scheduled runs did not ` + "happen so th
14633
14924
  await client.close().catch(() => {});
14634
14925
  }
14635
14926
  } : undefined;
14636
- const cheapCron = buildCheapCronHooks(config, process.env);
14927
+ const postOutbound = (args) => ipcClient.sendOutbound({
14928
+ type: "send_outbound",
14929
+ agentName,
14930
+ chatId: channel.chatId,
14931
+ ...args.threadId != null ? { threadId: args.threadId } : {},
14932
+ text: args.text,
14933
+ parseMode: args.parseMode
14934
+ });
14935
+ const cheapCron = buildCheapCronHooks(config, process.env, { agentName, postOutbound });
14637
14936
  if (cheapCron) {
14638
14937
  const recovered = recoverPendingEscalations({
14639
14938
  entries,
@@ -10989,11 +10989,29 @@ var PollSpecSchema = exports_external.discriminatedUnion("type", [
10989
10989
  HttpDiffPollSchema,
10990
10990
  TelegramReactionsPollSchema
10991
10991
  ]);
10992
+ var TelegramMessageActionSchema = exports_external.object({
10993
+ type: exports_external.literal("telegram-message"),
10994
+ 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."),
10995
+ parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
10996
+ });
10997
+ var WebhookActionSchema = exports_external.object({
10998
+ type: exports_external.literal("webhook"),
10999
+ 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."),
11000
+ method: exports_external.enum(["GET", "POST"]).default("POST"),
11001
+ headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
11002
+ body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
11003
+ 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.")
11004
+ });
11005
+ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
11006
+ TelegramMessageActionSchema,
11007
+ WebhookActionSchema
11008
+ ]);
10992
11009
  var ScheduleEntrySchema = exports_external.object({
10993
11010
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
10994
- prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
10995
- 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."),
11011
+ 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)."),
11012
+ 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)."),
10996
11013
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11014
+ action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
10997
11015
  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."),
10998
11016
  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."),
10999
11017
  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."),
@@ -11010,13 +11028,41 @@ var ScheduleEntrySchema = exports_external.object({
11010
11028
  message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11011
11029
  });
11012
11030
  }
11013
- if (kind === "prompt" && entry.poll) {
11031
+ if (kind !== "poll" && entry.poll) {
11014
11032
  ctx.addIssue({
11015
11033
  code: exports_external.ZodIssueCode.custom,
11016
11034
  path: ["poll"],
11017
11035
  message: "`poll` is only valid when kind: poll."
11018
11036
  });
11019
11037
  }
11038
+ if (kind === "action" && !entry.action) {
11039
+ ctx.addIssue({
11040
+ code: exports_external.ZodIssueCode.custom,
11041
+ path: ["action"],
11042
+ message: "kind: action requires an `action` spec (telegram-message or webhook)."
11043
+ });
11044
+ }
11045
+ if (kind !== "action" && entry.action) {
11046
+ ctx.addIssue({
11047
+ code: exports_external.ZodIssueCode.custom,
11048
+ path: ["action"],
11049
+ message: "`action` is only valid when kind: action."
11050
+ });
11051
+ }
11052
+ if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
11053
+ ctx.addIssue({
11054
+ code: exports_external.ZodIssueCode.custom,
11055
+ path: ["prompt"],
11056
+ message: `kind: ${kind} requires a non-empty \`prompt\`.`
11057
+ });
11058
+ }
11059
+ if (kind === "action" && entry.prompt !== undefined) {
11060
+ ctx.addIssue({
11061
+ code: exports_external.ZodIssueCode.custom,
11062
+ path: ["prompt"],
11063
+ message: "`prompt` is not valid for kind: action (an action never fires a model)."
11064
+ });
11065
+ }
11020
11066
  });
11021
11067
  var AgentSoulSchema = exports_external.object({
11022
11068
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -11737,11 +11737,29 @@ var PollSpecSchema = exports_external.discriminatedUnion("type", [
11737
11737
  HttpDiffPollSchema,
11738
11738
  TelegramReactionsPollSchema
11739
11739
  ]);
11740
+ var TelegramMessageActionSchema = exports_external.object({
11741
+ type: exports_external.literal("telegram-message"),
11742
+ 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."),
11743
+ parse_mode: exports_external.enum(["html", "text"]).default("html").describe("Telegram parse mode for the message body.")
11744
+ });
11745
+ var WebhookActionSchema = exports_external.object({
11746
+ type: exports_external.literal("webhook"),
11747
+ 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."),
11748
+ method: exports_external.enum(["GET", "POST"]).default("POST"),
11749
+ headers: exports_external.record(exports_external.string()).optional().describe("Static headers; {{secret}} substitution applies."),
11750
+ body: exports_external.string().optional().describe("Static request body; {{secret}} substitution applies."),
11751
+ 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.")
11752
+ });
11753
+ var ActionSpecSchema = exports_external.discriminatedUnion("type", [
11754
+ TelegramMessageActionSchema,
11755
+ WebhookActionSchema
11756
+ ]);
11740
11757
  var ScheduleEntrySchema = exports_external.object({
11741
11758
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
11742
- prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
11743
- 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."),
11759
+ 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)."),
11760
+ 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)."),
11744
11761
  poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
11762
+ action: ActionSpecSchema.optional().describe("Required iff kind=action. The declarative action spec (telegram-message or webhook)."),
11745
11763
  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."),
11746
11764
  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."),
11747
11765
  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."),
@@ -11758,13 +11776,41 @@ var ScheduleEntrySchema = exports_external.object({
11758
11776
  message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11759
11777
  });
11760
11778
  }
11761
- if (kind === "prompt" && entry.poll) {
11779
+ if (kind !== "poll" && entry.poll) {
11762
11780
  ctx.addIssue({
11763
11781
  code: exports_external.ZodIssueCode.custom,
11764
11782
  path: ["poll"],
11765
11783
  message: "`poll` is only valid when kind: poll."
11766
11784
  });
11767
11785
  }
11786
+ if (kind === "action" && !entry.action) {
11787
+ ctx.addIssue({
11788
+ code: exports_external.ZodIssueCode.custom,
11789
+ path: ["action"],
11790
+ message: "kind: action requires an `action` spec (telegram-message or webhook)."
11791
+ });
11792
+ }
11793
+ if (kind !== "action" && entry.action) {
11794
+ ctx.addIssue({
11795
+ code: exports_external.ZodIssueCode.custom,
11796
+ path: ["action"],
11797
+ message: "`action` is only valid when kind: action."
11798
+ });
11799
+ }
11800
+ if (kind !== "action" && (entry.prompt === undefined || entry.prompt.trim() === "")) {
11801
+ ctx.addIssue({
11802
+ code: exports_external.ZodIssueCode.custom,
11803
+ path: ["prompt"],
11804
+ message: `kind: ${kind} requires a non-empty \`prompt\`.`
11805
+ });
11806
+ }
11807
+ if (kind === "action" && entry.prompt !== undefined) {
11808
+ ctx.addIssue({
11809
+ code: exports_external.ZodIssueCode.custom,
11810
+ path: ["prompt"],
11811
+ message: "`prompt` is not valid for kind: action (an action never fires a model)."
11812
+ });
11813
+ }
11768
11814
  });
11769
11815
  var AgentSoulSchema = exports_external.object({
11770
11816
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),