switchroom 0.15.12 → 0.15.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/agent-scheduler/index.js +324 -14
  2. package/dist/auth-broker/index.js +61 -4
  3. package/dist/cli/notion-write-pretool.mjs +61 -4
  4. package/dist/cli/switchroom.js +402 -113
  5. package/dist/host-control/main.js +61 -4
  6. package/dist/vault/approvals/kernel-server.js +62 -5
  7. package/dist/vault/broker/server.js +62 -5
  8. package/package.json +1 -1
  9. package/profiles/_base/cron-session.sh.hbs +30 -13
  10. package/profiles/_shared/agent-self-service.md.hbs +37 -0
  11. package/telegram-plugin/bridge/bridge.ts +38 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +31 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +536 -53
  14. package/telegram-plugin/dist/server.js +31 -1
  15. package/telegram-plugin/gateway/gateway.ts +169 -6
  16. package/telegram-plugin/gateway/ipc-protocol.ts +31 -1
  17. package/telegram-plugin/gateway/ipc-server.ts +29 -0
  18. package/telegram-plugin/gateway/linear-activity.ts +145 -0
  19. package/telegram-plugin/runtime-metrics.ts +14 -0
  20. package/telegram-plugin/scoped-approval.ts +253 -0
  21. package/telegram-plugin/tests/bridge-liveness-override.test.ts +21 -0
  22. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +54 -0
  23. package/telegram-plugin/tests/linear-agent-activity.test.ts +1 -1
  24. package/telegram-plugin/tests/linear-create-issue.test.ts +211 -0
  25. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +13 -0
  26. package/telegram-plugin/tests/runtime-metrics.test.ts +9 -0
  27. package/telegram-plugin/tests/scoped-approval.test.ts +254 -0
  28. package/telegram-plugin/tests/send-outbound-wiring.test.ts +63 -0
@@ -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')"),
@@ -11150,7 +11196,8 @@ var TelegramChannelSchema = exports_external.object({
11150
11196
  linear_agent: exports_external.object({
11151
11197
  enabled: exports_external.boolean(),
11152
11198
  token: exports_external.string().describe("vault:<key> reference to the Linear OAuth app token (actor=app). " + "Resolved at runtime via the vault broker (canonically " + "vault:linear/<agent>/token). Never an inline literal."),
11153
- workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace.")
11199
+ workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace."),
11200
+ default_team_id: exports_external.string().optional().describe("Optional Linear team id new captured issues file into when the " + "agent doesn't pass an explicit team_id. Unnecessary for a " + "single-team workspace (auto-resolved); set it only when the " + "workspace has multiple teams. Manage via " + "`switchroom linear-agent set-team <agent> <team>`.")
11154
11201
  }).optional().describe("Linear first-class agent integration (#2298). When enabled, the " + "agent appears in a Linear workspace as an app actor (own name/" + "avatar, @-mentionable, delegate-assignable). Linear AgentSessionEvent " + "webhooks (mention / delegation) wake the agent instantly via the " + "same gateway inject path as webhook_dispatch, tagged " + 'meta.source="linear" with the agent_session_id, and the agent ' + "responds with structured AgentActivity (thought/message/complete/" + "error) via the linear_agent_activity MCP tool. Builds the " + "session-lifecycle layer on top of the plain webhook_sources:[linear] " + "+ webhook_dispatch support (#2272). The OAuth app token is stored in " + "the vault and referenced here as vault:linear/<agent>/token; run " + "`switchroom linear-agent setup <agent>` to provision it. Off by " + "default — opt in per agent. Cascades from " + "defaults.channels.telegram.linear_agent."),
11155
11202
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
11156
11203
  default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
@@ -12024,6 +12071,16 @@ function mergeAgentConfig(defaultsIn, agentIn) {
12024
12071
  }
12025
12072
  merged.reaction_dispatch = combined;
12026
12073
  }
12074
+ const linearEnabled = merged.channels?.telegram?.linear_agent?.enabled === true;
12075
+ if (linearEnabled) {
12076
+ const rd = merged.reaction_dispatch;
12077
+ if (!rd || rd.emojis === undefined) {
12078
+ merged.reaction_dispatch = {
12079
+ enabled: rd?.enabled ?? true,
12080
+ emojis: ["\uD83D\uDC68‍\uD83D\uDCBB", "\uD83D\uDCCC"]
12081
+ };
12082
+ }
12083
+ }
12027
12084
  if (defaults.resources || merged.resources) {
12028
12085
  const d = defaults.resources ?? {};
12029
12086
  const a = merged.resources ?? {};
@@ -12233,17 +12290,19 @@ function collectScheduleEntries(config) {
12233
12290
  const schedule = resolved.schedule ?? [];
12234
12291
  for (let i = 0;i < schedule.length; i++) {
12235
12292
  const entry = schedule[i];
12293
+ const auditMaterial = entry.prompt ?? `action:${JSON.stringify(entry.action ?? {})}`;
12236
12294
  out.push({
12237
12295
  agent,
12238
12296
  scheduleIndex: i,
12239
12297
  cron: entry.cron,
12240
- prompt: entry.prompt,
12241
- 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),
12242
12300
  ...entry.topic !== undefined ? { topic: entry.topic } : {},
12243
12301
  ...entry.kind !== undefined ? { kind: entry.kind } : {},
12244
12302
  ...entry.model !== undefined ? { model: entry.model } : {},
12245
12303
  ...entry.context !== undefined ? { context: entry.context } : {},
12246
- ...entry.poll !== undefined ? { poll: entry.poll } : {}
12304
+ ...entry.poll !== undefined ? { poll: entry.poll } : {},
12305
+ ...entry.action !== undefined ? { action: entry.action } : {}
12247
12306
  });
12248
12307
  }
12249
12308
  }
@@ -12259,7 +12318,7 @@ function dispatchAsInbound(entry, options, dispatcher) {
12259
12318
  user: "cron",
12260
12319
  userId: 0,
12261
12320
  ts,
12262
- text: entry.prompt,
12321
+ text: entry.prompt ?? "",
12263
12322
  meta: {
12264
12323
  source: "cron",
12265
12324
  schedule_index: String(entry.scheduleIndex),
@@ -12287,6 +12346,9 @@ function resolveCronModel(model) {
12287
12346
  return isKnownCheapModel(model) ? model : DEFAULT_CRON_MODEL;
12288
12347
  }
12289
12348
  function resolveCronRouting(input, opts) {
12349
+ if ((input.kind ?? "prompt") === "action") {
12350
+ return { tier: "action", session: null, customModelDowngrade: false };
12351
+ }
12290
12352
  if (!opts.cheapCronEnabled) {
12291
12353
  return { tier: "main", session: "main", customModelDowngrade: false };
12292
12354
  }
@@ -12318,10 +12380,97 @@ function resolveEscalationRouting(input, opts) {
12318
12380
  return resolveCronRouting({ ...input, kind: "prompt" }, opts);
12319
12381
  }
12320
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
+
12321
12431
  // src/scheduler/tier-selector.ts
12322
12432
  var DEFAULT_FREQUENT_GAP_MIN = 60;
12323
- function applyDefaultTier(entry, _frequentGapMin = DEFAULT_FREQUENT_GAP_MIN) {
12324
- 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;
12325
12474
  }
12326
12475
 
12327
12476
  // src/agent-scheduler/cheap-cron-wiring.ts
@@ -12538,6 +12687,99 @@ async function readCapped(res, maxBytes) {
12538
12687
  return new TextDecoder().decode(buf);
12539
12688
  }
12540
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
+
12541
12783
  // src/scheduler/poll-state.ts
12542
12784
  import { mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
12543
12785
  import { dirname } from "node:path";
@@ -13061,6 +13303,8 @@ function buildCheapCronHooks(config, env, deps = {}) {
13061
13303
  const fetchImpl = deps.fetchImpl ?? fetch;
13062
13304
  const lookup = deps.lookup ?? defaultLookup;
13063
13305
  const now = deps.now ?? Date.now;
13306
+ const agentName = deps.agentName ?? env.SWITCHROOM_AGENT_NAME ?? "-";
13307
+ const postOutbound = deps.postOutbound;
13064
13308
  const runPoll = async (spec, prevCursor) => {
13065
13309
  if (spec.type === "http-diff") {
13066
13310
  return runHttpDiffPoll(spec, prevCursor, {
@@ -13073,7 +13317,18 @@ function buildCheapCronHooks(config, env, deps = {}) {
13073
13317
  }
13074
13318
  return { hit: false, baseline: false, error: `poll type '${spec.type}' not yet wired (staged)` };
13075
13319
  };
13076
- 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 };
13077
13332
  }
13078
13333
 
13079
13334
  // src/telegram/topic-router.ts
@@ -13865,6 +14120,17 @@ function createInjectIpcClient(options) {
13865
14120
  return false;
13866
14121
  try {
13867
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) + `
13868
14134
  `);
13869
14135
  } catch (err) {
13870
14136
  log(`scheduler ipc: write failed: ${err.message}`);
@@ -14218,7 +14484,7 @@ function readRecentFires(jsonlPath) {
14218
14484
 
14219
14485
  // src/agent-scheduler/index.ts
14220
14486
  function templateEscalationPrompt(prompt, diff) {
14221
- return prompt.replace(/\{\{\s*diff\s*\}\}/g, diff);
14487
+ return (prompt ?? "").replace(/\{\{\s*diff\s*\}\}/g, diff);
14222
14488
  }
14223
14489
  function recoverPendingEscalations(opts) {
14224
14490
  let recovered = 0;
@@ -14363,6 +14629,28 @@ function registerAgentSchedule(opts) {
14363
14629
  });
14364
14630
  return;
14365
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
+ }
14366
14654
  let delivered = false;
14367
14655
  let summary = "";
14368
14656
  try {
@@ -14536,6 +14824,19 @@ async function main() {
14536
14824
  const cheapEnabledForReplay = isCheapCronEnabled(process.env);
14537
14825
  for (const m of missed) {
14538
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
+ }
14539
14840
  if (cheapEnabledForReplay && m.entry.kind === "poll") {
14540
14841
  sink.recordFire({
14541
14842
  agent: m.entry.agent,
@@ -14566,7 +14867,8 @@ async function main() {
14566
14867
  process.stdout.write(`agent-scheduler: ${staleSkipped.length} scheduled run(s) ` + `skipped (older than ${windowMinutes}min window) — notifying user
14567
14868
  `);
14568
14869
  const lines = staleSkipped.map((s) => {
14569
- 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);
14570
14872
  return `- "${oneLine}" — cron \`${s.entry.cron}\`, ` + `most recent missed run ~${new Date(s.expectedFireMs).toISOString()}`;
14571
14873
  });
14572
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:
@@ -14622,7 +14924,15 @@ Briefly and plainly tell the user these scheduled runs did not ` + "happen so th
14622
14924
  await client.close().catch(() => {});
14623
14925
  }
14624
14926
  } : undefined;
14625
- 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 });
14626
14936
  if (cheapCron) {
14627
14937
  const recovered = recoverPendingEscalations({
14628
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')"),
@@ -11150,7 +11196,8 @@ var TelegramChannelSchema = exports_external.object({
11150
11196
  linear_agent: exports_external.object({
11151
11197
  enabled: exports_external.boolean(),
11152
11198
  token: exports_external.string().describe("vault:<key> reference to the Linear OAuth app token (actor=app). " + "Resolved at runtime via the vault broker (canonically " + "vault:linear/<agent>/token). Never an inline literal."),
11153
- workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace.")
11199
+ workspace_id: exports_external.string().optional().describe("Optional Linear workspace (organization) id this agent is " + "installed into. Informational — used for setup hints and " + "multi-workspace disambiguation; the token already scopes the " + "app to its workspace."),
11200
+ default_team_id: exports_external.string().optional().describe("Optional Linear team id new captured issues file into when the " + "agent doesn't pass an explicit team_id. Unnecessary for a " + "single-team workspace (auto-resolved); set it only when the " + "workspace has multiple teams. Manage via " + "`switchroom linear-agent set-team <agent> <team>`.")
11154
11201
  }).optional().describe("Linear first-class agent integration (#2298). When enabled, the " + "agent appears in a Linear workspace as an app actor (own name/" + "avatar, @-mentionable, delegate-assignable). Linear AgentSessionEvent " + "webhooks (mention / delegation) wake the agent instantly via the " + "same gateway inject path as webhook_dispatch, tagged " + 'meta.source="linear" with the agent_session_id, and the agent ' + "responds with structured AgentActivity (thought/message/complete/" + "error) via the linear_agent_activity MCP tool. Builds the " + "session-lifecycle layer on top of the plain webhook_sources:[linear] " + "+ webhook_dispatch support (#2272). The OAuth app token is stored in " + "the vault and referenced here as vault:linear/<agent>/token; run " + "`switchroom linear-agent setup <agent>` to provision it. Off by " + "default — opt in per agent. Cascades from " + "defaults.channels.telegram.linear_agent."),
11155
11202
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
11156
11203
  default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
@@ -12024,6 +12071,16 @@ function mergeAgentConfig(defaultsIn, agentIn) {
12024
12071
  }
12025
12072
  merged.reaction_dispatch = combined;
12026
12073
  }
12074
+ const linearEnabled = merged.channels?.telegram?.linear_agent?.enabled === true;
12075
+ if (linearEnabled) {
12076
+ const rd = merged.reaction_dispatch;
12077
+ if (!rd || rd.emojis === undefined) {
12078
+ merged.reaction_dispatch = {
12079
+ enabled: rd?.enabled ?? true,
12080
+ emojis: ["\uD83D\uDC68‍\uD83D\uDCBB", "\uD83D\uDCCC"]
12081
+ };
12082
+ }
12083
+ }
12027
12084
  if (defaults.resources || merged.resources) {
12028
12085
  const d = defaults.resources ?? {};
12029
12086
  const a = merged.resources ?? {};