switchroom 0.14.91 → 0.14.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6932,7 +6932,7 @@ var require_public_api = __commonJS((exports) => {
6932
6932
  });
6933
6933
 
6934
6934
  // src/agent-scheduler/index.ts
6935
- import { resolve as resolve4, join as join2 } from "node:path";
6935
+ import { resolve as resolve4, join as join3 } from "node:path";
6936
6936
 
6937
6937
  // src/config/loader.ts
6938
6938
  import { readFileSync as readFileSync2, existsSync as existsSync3 } from "node:fs";
@@ -10969,15 +10969,54 @@ var AgentBindMountSchema = exports_external.object({
10969
10969
  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)."),
10970
10970
  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'.")
10971
10971
  });
10972
+ var HttpDiffPollSchema = exports_external.object({
10973
+ type: exports_external.literal("http-diff"),
10974
+ 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."),
10975
+ method: exports_external.enum(["GET", "POST"]).default("GET"),
10976
+ headers: exports_external.record(exports_external.string()).optional(),
10977
+ 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."),
10978
+ diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
10979
+ state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
10980
+ });
10981
+ var TelegramReactionsPollSchema = exports_external.object({
10982
+ type: exports_external.literal("telegram-reactions"),
10983
+ 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)."),
10984
+ emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68‍\uD83D\uDCBB)."),
10985
+ lookback: exports_external.number().int().positive().max(200).default(40),
10986
+ state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
10987
+ });
10988
+ var PollSpecSchema = exports_external.discriminatedUnion("type", [
10989
+ HttpDiffPollSchema,
10990
+ TelegramReactionsPollSchema
10991
+ ]);
10972
10992
  var ScheduleEntrySchema = exports_external.object({
10973
10993
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
10974
- prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
10975
- 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."),
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."),
10996
+ poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
10997
+ 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
+ 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."),
10976
10999
  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."),
10977
11000
  topic: exports_external.union([
10978
11001
  exports_external.string().min(1, "topic alias must be non-empty"),
10979
11002
  exports_external.number().int().positive("topic ID must be a positive integer")
10980
11003
  ]).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.")
11004
+ }).superRefine((entry, ctx) => {
11005
+ const kind = entry.kind ?? "prompt";
11006
+ if (kind === "poll" && !entry.poll) {
11007
+ ctx.addIssue({
11008
+ code: exports_external.ZodIssueCode.custom,
11009
+ path: ["poll"],
11010
+ message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
11011
+ });
11012
+ }
11013
+ if (kind === "prompt" && entry.poll) {
11014
+ ctx.addIssue({
11015
+ code: exports_external.ZodIssueCode.custom,
11016
+ path: ["poll"],
11017
+ message: "`poll` is only valid when kind: poll."
11018
+ });
11019
+ }
10981
11020
  });
10982
11021
  var AgentSoulSchema = exports_external.object({
10983
11022
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -11424,6 +11463,13 @@ var HostdConfigSchema = exports_external.object({
11424
11463
  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."),
11425
11464
  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.")
11426
11465
  });
11466
+ var CronEgressSchema = exports_external.object({
11467
+ 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."),
11468
+ 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.")
11469
+ });
11470
+ var CronConfigSchema = exports_external.object({
11471
+ egress: CronEgressSchema.optional().describe("SSRF/exfil fence for http-diff polls.")
11472
+ });
11427
11473
  var SwitchroomConfigSchema = exports_external.object({
11428
11474
  switchroom: exports_external.object({
11429
11475
  version: exports_external.literal(1).describe("Config schema version"),
@@ -11472,7 +11518,8 @@ var SwitchroomConfigSchema = exports_external.object({
11472
11518
  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."),
11473
11519
  agents: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
11474
11520
  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)"
11475
- }), AgentSchema).describe("Map of agent name to agent configuration")
11521
+ }), AgentSchema).describe("Map of agent name to agent configuration"),
11522
+ 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.")
11476
11523
  });
11477
11524
 
11478
11525
  // src/config/paths.ts
@@ -12162,7 +12209,11 @@ function collectScheduleEntries(config) {
12162
12209
  cron: entry.cron,
12163
12210
  prompt: entry.prompt,
12164
12211
  promptKey: createHash("sha256").update(entry.prompt).digest("hex").slice(0, 12),
12165
- ...entry.topic !== undefined ? { topic: entry.topic } : {}
12212
+ ...entry.topic !== undefined ? { topic: entry.topic } : {},
12213
+ ...entry.kind !== undefined ? { kind: entry.kind } : {},
12214
+ ...entry.model !== undefined ? { model: entry.model } : {},
12215
+ ...entry.context !== undefined ? { context: entry.context } : {},
12216
+ ...entry.poll !== undefined ? { poll: entry.poll } : {}
12166
12217
  });
12167
12218
  }
12168
12219
  }
@@ -12182,13 +12233,813 @@ function dispatchAsInbound(entry, options, dispatcher) {
12182
12233
  meta: {
12183
12234
  source: "cron",
12184
12235
  schedule_index: String(entry.scheduleIndex),
12185
- prompt_key: entry.promptKey
12236
+ prompt_key: entry.promptKey,
12237
+ ...options.session === "cron" ? { session: "cron" } : {},
12238
+ ...options.session === "cron" && options.model ? { model: options.model } : {}
12186
12239
  }
12187
12240
  };
12188
12241
  const delivered = dispatcher.sendToAgent(entry.agent, message);
12189
12242
  return { delivered, message };
12190
12243
  }
12191
12244
 
12245
+ // src/scheduler/cron-routing.ts
12246
+ var DEFAULT_CRON_MODEL = "claude-sonnet-4-6";
12247
+ var CHEAP_MODEL_RE = /(^|[^a-z])(sonnet|haiku)([^a-z]|$)/i;
12248
+ var OPUS_MODEL_RE = /opus/i;
12249
+ function isKnownCheapModel(model) {
12250
+ return model !== undefined && CHEAP_MODEL_RE.test(model);
12251
+ }
12252
+ function isCheapCronEnabled(env = process.env) {
12253
+ const v = (env.SWITCHROOM_CHEAP_CRON ?? "").toLowerCase();
12254
+ return v === "1" || v === "true" || v === "on";
12255
+ }
12256
+ function resolveCronModel(model) {
12257
+ return isKnownCheapModel(model) ? model : DEFAULT_CRON_MODEL;
12258
+ }
12259
+ function resolveCronRouting(input, opts) {
12260
+ if (!opts.cheapCronEnabled) {
12261
+ return { tier: "main", session: "main", customModelDowngrade: false };
12262
+ }
12263
+ const kind = input.kind ?? "prompt";
12264
+ let context;
12265
+ let customModelDowngrade = false;
12266
+ if (input.context) {
12267
+ context = input.context;
12268
+ } else if (isKnownCheapModel(input.model)) {
12269
+ context = "fresh";
12270
+ } else {
12271
+ context = "agent";
12272
+ customModelDowngrade = input.model !== undefined && !isKnownCheapModel(input.model) && !OPUS_MODEL_RE.test(input.model);
12273
+ }
12274
+ if (kind === "poll") {
12275
+ return { tier: "poll", session: null, customModelDowngrade };
12276
+ }
12277
+ if (context === "fresh") {
12278
+ return {
12279
+ tier: "cheap",
12280
+ session: "cron",
12281
+ cronModel: resolveCronModel(input.model),
12282
+ customModelDowngrade
12283
+ };
12284
+ }
12285
+ return { tier: "main", session: "main", customModelDowngrade };
12286
+ }
12287
+ function resolveEscalationRouting(input, opts) {
12288
+ return resolveCronRouting({ ...input, kind: "prompt" }, opts);
12289
+ }
12290
+
12291
+ // src/agent-scheduler/cheap-cron-wiring.ts
12292
+ import { lookup as dnsLookup } from "node:dns/promises";
12293
+
12294
+ // src/scheduler/poll-egress.ts
12295
+ import { isIP } from "node:net";
12296
+ var ok = { ok: true };
12297
+ var deny = (reason) => ({ ok: false, reason });
12298
+ function normalizeHost(host) {
12299
+ return host.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
12300
+ }
12301
+ function isBlockedAddress(addr) {
12302
+ const v = isIP(addr);
12303
+ if (v === 4)
12304
+ return isBlockedV4(addr);
12305
+ if (v === 6)
12306
+ return isBlockedV6(addr);
12307
+ return false;
12308
+ }
12309
+ function isBlockedV4(addr) {
12310
+ const o = addr.split(".").map((n) => Number(n));
12311
+ if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255))
12312
+ return true;
12313
+ const [a, b] = o;
12314
+ if (a === 0)
12315
+ return true;
12316
+ if (a === 10)
12317
+ return true;
12318
+ if (a === 127)
12319
+ return true;
12320
+ if (a === 169 && b === 254)
12321
+ return true;
12322
+ if (a === 172 && b >= 16 && b <= 31)
12323
+ return true;
12324
+ if (a === 192 && b === 168)
12325
+ return true;
12326
+ if (a === 100 && b >= 64 && b <= 127)
12327
+ return true;
12328
+ if (a >= 224)
12329
+ return true;
12330
+ return false;
12331
+ }
12332
+ function isBlockedV6(addr) {
12333
+ const a = addr.toLowerCase();
12334
+ if (a === "::" || a === "::1")
12335
+ return true;
12336
+ if (a.startsWith("fe80"))
12337
+ return true;
12338
+ if (a.startsWith("fc") || a.startsWith("fd"))
12339
+ return true;
12340
+ if (a.startsWith("ff"))
12341
+ return true;
12342
+ const mapped = a.match(/::ffff:(\d+\.\d+\.\d+\.\d+)$/);
12343
+ if (mapped)
12344
+ return isBlockedV4(mapped[1]);
12345
+ const hexMapped = a.match(/::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
12346
+ if (hexMapped) {
12347
+ const hi = parseInt(hexMapped[1], 16);
12348
+ const lo = parseInt(hexMapped[2], 16);
12349
+ const v4 = `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
12350
+ return isBlockedV4(v4);
12351
+ }
12352
+ return false;
12353
+ }
12354
+ function checkEgressUrl(rawUrl, allow) {
12355
+ let u;
12356
+ try {
12357
+ u = new URL(rawUrl);
12358
+ } catch {
12359
+ return deny(`unparseable url`);
12360
+ }
12361
+ if (u.protocol !== "https:")
12362
+ return deny(`scheme ${u.protocol} not allowed (https only)`);
12363
+ if (u.username || u.password)
12364
+ return deny(`userinfo in url not allowed`);
12365
+ const host = normalizeHost(u.hostname);
12366
+ if (!host)
12367
+ return deny(`empty host`);
12368
+ if (isIP(host) && isBlockedAddress(host))
12369
+ return deny(`blocked ip literal ${host}`);
12370
+ const allowed = allow.hosts.map(normalizeHost);
12371
+ if (!allowed.includes(host))
12372
+ return deny(`host ${host} not on egress allowlist`);
12373
+ return ok;
12374
+ }
12375
+ function checkSecretBinding(secretName, host, allow) {
12376
+ const bound = allow.secretBindings[secretName];
12377
+ if (!bound)
12378
+ return deny(`secret ${secretName} has no host binding`);
12379
+ if (normalizeHost(bound) !== normalizeHost(host)) {
12380
+ return deny(`secret ${secretName} bound to ${bound}, not ${host}`);
12381
+ }
12382
+ return ok;
12383
+ }
12384
+ function checkHttpDiffEgress(rawUrl, secrets, allow) {
12385
+ const urlCheck = checkEgressUrl(rawUrl, allow);
12386
+ if (!urlCheck.ok)
12387
+ return urlCheck;
12388
+ const host = normalizeHost(new URL(rawUrl).hostname);
12389
+ for (const s of secrets) {
12390
+ const sc = checkSecretBinding(s, host, allow);
12391
+ if (!sc.ok)
12392
+ return sc;
12393
+ }
12394
+ return ok;
12395
+ }
12396
+
12397
+ // src/scheduler/poll-engine.ts
12398
+ var TOKEN_RE = /\.([a-zA-Z0-9_]+)|\[(\*|\d+)\]/g;
12399
+ function extractLeaves(root, jsonpath) {
12400
+ const path = jsonpath.startsWith("$") ? jsonpath.slice(1) : jsonpath;
12401
+ let nodes = [root];
12402
+ let m;
12403
+ TOKEN_RE.lastIndex = 0;
12404
+ while ((m = TOKEN_RE.exec(path)) !== null) {
12405
+ const key = m[1];
12406
+ const idx = m[2];
12407
+ const next = [];
12408
+ for (const node of nodes) {
12409
+ if (node == null)
12410
+ continue;
12411
+ if (key !== undefined) {
12412
+ if (typeof node === "object")
12413
+ next.push(node[key]);
12414
+ } else if (idx === "*") {
12415
+ if (Array.isArray(node))
12416
+ next.push(...node);
12417
+ } else if (idx !== undefined) {
12418
+ if (Array.isArray(node))
12419
+ next.push(node[Number(idx)]);
12420
+ }
12421
+ }
12422
+ nodes = next;
12423
+ }
12424
+ return nodes.filter((n) => n !== undefined);
12425
+ }
12426
+ function cursorOf(leaves) {
12427
+ const nums = leaves.map((l) => Number(l)).filter((n) => Number.isFinite(n));
12428
+ if (nums.length === leaves.length && nums.length > 0) {
12429
+ return { cursor: String(Math.max(...nums)), numeric: true, count: leaves.length };
12430
+ }
12431
+ return { cursor: `n:${leaves.length}`, numeric: false, count: leaves.length };
12432
+ }
12433
+ function cursorAdvanced(prev, next, numeric) {
12434
+ if (prev === undefined)
12435
+ return false;
12436
+ if (numeric)
12437
+ return Number(next) > Number(prev);
12438
+ return next !== prev;
12439
+ }
12440
+ function substituteSecrets(headers, secretValues) {
12441
+ const out = {};
12442
+ for (const [k, v] of Object.entries(headers)) {
12443
+ out[k] = v.replace(/\{\{([a-zA-Z0-9_\-/]+)\}\}/g, (_, name) => secretValues[name] ?? "");
12444
+ }
12445
+ return out;
12446
+ }
12447
+ async function runHttpDiffPoll(spec, prevCursor, deps) {
12448
+ const gate = checkHttpDiffEgress(spec.url, spec.secrets, deps.allow);
12449
+ if (!gate.ok)
12450
+ return { hit: false, baseline: false, error: `egress denied: ${gate.reason}` };
12451
+ const host = normalizeHost(new URL(spec.url).hostname);
12452
+ try {
12453
+ const ip = await deps.lookup(host);
12454
+ if (ip && isBlockedAddress(ip))
12455
+ return { hit: false, baseline: false, error: `resolved ip ${ip} blocked` };
12456
+ } catch (e) {
12457
+ return { hit: false, baseline: false, error: `dns lookup failed: ${e.message}` };
12458
+ }
12459
+ const secretValues = {};
12460
+ for (const name of spec.secrets) {
12461
+ try {
12462
+ secretValues[name] = await deps.resolveSecret(name);
12463
+ } catch (e) {
12464
+ return { hit: false, baseline: false, error: `secret ${name}: ${e.message}` };
12465
+ }
12466
+ }
12467
+ const headers = substituteSecrets(spec.headers ?? {}, secretValues);
12468
+ const controller = new AbortController;
12469
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? 5000);
12470
+ let json;
12471
+ try {
12472
+ const res = await deps.fetchImpl(spec.url, {
12473
+ method: spec.method,
12474
+ headers,
12475
+ redirect: "error",
12476
+ signal: controller.signal
12477
+ });
12478
+ if (!res.ok)
12479
+ return { hit: false, baseline: false, error: `http ${res.status}` };
12480
+ const text = await readCapped(res, deps.maxBytes ?? 1024 * 1024);
12481
+ json = JSON.parse(text);
12482
+ } catch (e) {
12483
+ return { hit: false, baseline: false, error: `fetch failed: ${e.message}` };
12484
+ } finally {
12485
+ clearTimeout(timer);
12486
+ }
12487
+ const leaves = extractLeaves(json, spec.diff_jsonpath);
12488
+ const { cursor, numeric, count } = cursorOf(leaves);
12489
+ if (prevCursor === undefined) {
12490
+ return { hit: false, baseline: true, cursor };
12491
+ }
12492
+ if (cursorAdvanced(prevCursor, cursor, numeric)) {
12493
+ const diff = numeric ? `cursor ${cursor} > ${prevCursor} (${count} item(s))` : `${count} item(s), cursor ${prevCursor} → ${cursor}`;
12494
+ return { hit: true, baseline: false, cursor, diff };
12495
+ }
12496
+ return { hit: false, baseline: false, cursor };
12497
+ }
12498
+ async function readCapped(res, maxBytes) {
12499
+ const buf = await res.arrayBuffer();
12500
+ if (buf.byteLength > maxBytes)
12501
+ throw new Error(`response ${buf.byteLength}B exceeds cap ${maxBytes}B`);
12502
+ return new TextDecoder().decode(buf);
12503
+ }
12504
+
12505
+ // src/scheduler/poll-state.ts
12506
+ import { mkdirSync, readFileSync as readFileSync3, renameSync, writeFileSync } from "node:fs";
12507
+ import { dirname } from "node:path";
12508
+ function createFilePollStateStore(path) {
12509
+ function read() {
12510
+ try {
12511
+ return JSON.parse(readFileSync3(path, "utf8"));
12512
+ } catch {
12513
+ return {};
12514
+ }
12515
+ }
12516
+ function write(state) {
12517
+ mkdirSync(dirname(path), { recursive: true });
12518
+ const tmp = `${path}.tmp`;
12519
+ writeFileSync(tmp, JSON.stringify(state, null, 2), "utf8");
12520
+ renameSync(tmp, path);
12521
+ }
12522
+ return {
12523
+ get(key) {
12524
+ return read()[key];
12525
+ },
12526
+ writeAhead(key, value, fireTs, diff) {
12527
+ const state = read();
12528
+ state[key] = { value, escalatedAt: fireTs, pendingEscalation: diff };
12529
+ write(state);
12530
+ },
12531
+ setBaseline(key, value) {
12532
+ const state = read();
12533
+ const prev = state[key];
12534
+ state[key] = { value, escalatedAt: prev?.escalatedAt };
12535
+ write(state);
12536
+ },
12537
+ clearPending(key) {
12538
+ const state = read();
12539
+ const e = state[key];
12540
+ if (e?.pendingEscalation !== undefined) {
12541
+ delete e.pendingEscalation;
12542
+ write(state);
12543
+ }
12544
+ }
12545
+ };
12546
+ }
12547
+
12548
+ // src/vault/broker/client.ts
12549
+ import * as net from "node:net";
12550
+ import * as fs from "node:fs";
12551
+ import { homedir as homedir2 } from "node:os";
12552
+ import { join } from "node:path";
12553
+
12554
+ // src/vault/broker/peercred.ts
12555
+ var RESERVED_AGENT_NAMES = new Set(["operator", "hostd"]);
12556
+ function isReservedAgentName(name) {
12557
+ return RESERVED_AGENT_NAMES.has(name);
12558
+ }
12559
+
12560
+ // src/vault/broker/protocol.ts
12561
+ var MAX_FRAME_BYTES = 64 * 1024;
12562
+ var AgentNameSchema = exports_external.string().min(1).max(64, "agent name max 64 chars").regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, "agent name must be kebab-case ASCII (alnum + _- only, first char alnum)").refine((s) => !isReservedAgentName(s), {
12563
+ message: "agent name is reserved"
12564
+ });
12565
+ var GetRequestSchema = exports_external.object({
12566
+ v: exports_external.literal(1),
12567
+ op: exports_external.literal("get"),
12568
+ key: exports_external.string().min(1),
12569
+ filename: exports_external.string().optional(),
12570
+ token: exports_external.string().optional()
12571
+ });
12572
+ var PutRequestSchema = exports_external.object({
12573
+ v: exports_external.literal(1),
12574
+ op: exports_external.literal("put"),
12575
+ key: exports_external.string().min(1),
12576
+ entry: exports_external.union([
12577
+ exports_external.object({ kind: exports_external.literal("string"), value: exports_external.string() }),
12578
+ exports_external.object({ kind: exports_external.literal("binary"), value: exports_external.string() })
12579
+ ]),
12580
+ token: exports_external.string().optional(),
12581
+ passphrase: exports_external.string().optional(),
12582
+ attest_via_posture: exports_external.boolean().optional()
12583
+ });
12584
+ var ListRequestSchema = exports_external.object({
12585
+ v: exports_external.literal(1),
12586
+ op: exports_external.literal("list"),
12587
+ token: exports_external.string().optional()
12588
+ });
12589
+ var MintGrantRequestSchema = exports_external.object({
12590
+ v: exports_external.literal(1),
12591
+ op: exports_external.literal("mint_grant"),
12592
+ agent: AgentNameSchema,
12593
+ keys: exports_external.array(exports_external.string().min(1)),
12594
+ ttl_seconds: exports_external.number().int().positive().nullable(),
12595
+ description: exports_external.string().optional(),
12596
+ write_keys: exports_external.array(exports_external.string().min(1)).optional(),
12597
+ passphrase: exports_external.string().optional(),
12598
+ attest_via_posture: exports_external.boolean().optional(),
12599
+ decision_id: exports_external.string().optional()
12600
+ });
12601
+ var ListGrantsRequestSchema = exports_external.object({
12602
+ v: exports_external.literal(1),
12603
+ op: exports_external.literal("list_grants"),
12604
+ agent: AgentNameSchema.optional(),
12605
+ passphrase: exports_external.string().optional(),
12606
+ attest_via_posture: exports_external.boolean().optional()
12607
+ });
12608
+ var RevokeGrantRequestSchema = exports_external.object({
12609
+ v: exports_external.literal(1),
12610
+ op: exports_external.literal("revoke_grant"),
12611
+ id: exports_external.string().min(1)
12612
+ });
12613
+ var StatusRequestSchema = exports_external.object({
12614
+ v: exports_external.literal(1),
12615
+ op: exports_external.literal("status")
12616
+ });
12617
+ var LockRequestSchema = exports_external.object({
12618
+ v: exports_external.literal(1),
12619
+ op: exports_external.literal("lock")
12620
+ });
12621
+ var PreflightAccessRequestSchema = exports_external.object({
12622
+ v: exports_external.literal(1),
12623
+ op: exports_external.literal("preflight_access"),
12624
+ agent: AgentNameSchema,
12625
+ keys: exports_external.array(exports_external.string().min(1)).min(1).max(128)
12626
+ });
12627
+ var OkPreflightAccessResponseSchema = exports_external.object({
12628
+ ok: exports_external.literal(true),
12629
+ op: exports_external.literal("preflight_access"),
12630
+ results: exports_external.array(exports_external.object({
12631
+ key: exports_external.string(),
12632
+ exists: exports_external.boolean(),
12633
+ acl_ok: exports_external.boolean(),
12634
+ acl_reason: exports_external.string().optional(),
12635
+ scope_ok: exports_external.boolean(),
12636
+ scope_reason: exports_external.string().optional()
12637
+ }))
12638
+ });
12639
+ var ApprovalRequestRequestSchema = exports_external.object({
12640
+ v: exports_external.literal(1),
12641
+ op: exports_external.literal("approval_request"),
12642
+ agent_unit: exports_external.string().min(1),
12643
+ scope: exports_external.string().min(1),
12644
+ action: exports_external.string().min(1),
12645
+ approver_set: exports_external.array(exports_external.string()),
12646
+ why: exports_external.string().optional(),
12647
+ ttl_ms: exports_external.number().int().positive().optional()
12648
+ });
12649
+ var ApprovalLookupRequestSchema = exports_external.object({
12650
+ v: exports_external.literal(1),
12651
+ op: exports_external.literal("approval_lookup"),
12652
+ agent_unit: exports_external.string().min(1),
12653
+ scope: exports_external.string().min(1),
12654
+ action: exports_external.string().min(1),
12655
+ current_approver_set: exports_external.array(exports_external.string())
12656
+ });
12657
+ var ApprovalConsumeRequestSchema = exports_external.object({
12658
+ v: exports_external.literal(1),
12659
+ op: exports_external.literal("approval_consume"),
12660
+ request_id: exports_external.string().regex(/^[0-9a-f]{32}$/)
12661
+ });
12662
+ var ApprovalRevokeRequestSchema = exports_external.object({
12663
+ v: exports_external.literal(1),
12664
+ op: exports_external.literal("approval_revoke"),
12665
+ decision_id: exports_external.string().min(1),
12666
+ actor: exports_external.string().min(1),
12667
+ reason: exports_external.string().optional()
12668
+ });
12669
+ var ApprovalListRequestSchema = exports_external.object({
12670
+ v: exports_external.literal(1),
12671
+ op: exports_external.literal("approval_list"),
12672
+ agent_unit: exports_external.string().optional()
12673
+ });
12674
+ var ApprovalDecisionModeSchema = exports_external.enum([
12675
+ "allow_once",
12676
+ "allow_always",
12677
+ "allow_ttl",
12678
+ "deny",
12679
+ "deny_perm"
12680
+ ]);
12681
+ var ApprovalRecordRequestSchema = exports_external.object({
12682
+ v: exports_external.literal(1),
12683
+ op: exports_external.literal("approval_record"),
12684
+ request_id: exports_external.string().regex(/^[0-9a-f]{32}$/),
12685
+ decision: ApprovalDecisionModeSchema,
12686
+ approver_set: exports_external.array(exports_external.string()),
12687
+ granted_by_user_id: exports_external.number().int(),
12688
+ ttl_ms: exports_external.number().int().positive().nullable().optional()
12689
+ });
12690
+ var ApprovalConsumeRecordRequestSchema = exports_external.object({
12691
+ v: exports_external.literal(1),
12692
+ op: exports_external.literal("approval_consume_record"),
12693
+ request_id: exports_external.string().regex(/^[0-9a-f]{32}$/),
12694
+ decision: ApprovalDecisionModeSchema,
12695
+ approver_set: exports_external.array(exports_external.string()),
12696
+ granted_by_user_id: exports_external.number().int(),
12697
+ ttl_ms: exports_external.number().int().positive().nullable().optional()
12698
+ });
12699
+ var RequestSchema = exports_external.discriminatedUnion("op", [
12700
+ GetRequestSchema,
12701
+ PutRequestSchema,
12702
+ ListRequestSchema,
12703
+ StatusRequestSchema,
12704
+ LockRequestSchema,
12705
+ PreflightAccessRequestSchema,
12706
+ MintGrantRequestSchema,
12707
+ ListGrantsRequestSchema,
12708
+ RevokeGrantRequestSchema,
12709
+ ApprovalRequestRequestSchema,
12710
+ ApprovalLookupRequestSchema,
12711
+ ApprovalConsumeRequestSchema,
12712
+ ApprovalRevokeRequestSchema,
12713
+ ApprovalListRequestSchema,
12714
+ ApprovalRecordRequestSchema,
12715
+ ApprovalConsumeRecordRequestSchema
12716
+ ]);
12717
+ var VaultEntrySchema = exports_external.union([
12718
+ exports_external.object({ kind: exports_external.literal("string"), value: exports_external.string() }),
12719
+ exports_external.object({ kind: exports_external.literal("binary"), value: exports_external.string() }),
12720
+ exports_external.object({
12721
+ kind: exports_external.literal("files"),
12722
+ files: exports_external.record(exports_external.string(), exports_external.object({
12723
+ encoding: exports_external.enum(["utf8", "base64"]),
12724
+ value: exports_external.string()
12725
+ }))
12726
+ })
12727
+ ]);
12728
+ var ErrorCode = exports_external.enum([
12729
+ "LOCKED",
12730
+ "DENIED",
12731
+ "UNKNOWN_KEY",
12732
+ "BAD_REQUEST",
12733
+ "INTERNAL"
12734
+ ]);
12735
+ var OkEntryResponseSchema = exports_external.object({
12736
+ ok: exports_external.literal(true),
12737
+ entry: VaultEntrySchema
12738
+ });
12739
+ var OkKeysResponseSchema = exports_external.object({
12740
+ ok: exports_external.literal(true),
12741
+ keys: exports_external.array(exports_external.string())
12742
+ });
12743
+ var BrokerStatus = exports_external.object({
12744
+ unlocked: exports_external.boolean(),
12745
+ keyCount: exports_external.number().int().nonnegative(),
12746
+ uptimeSec: exports_external.number().nonnegative()
12747
+ });
12748
+ var OkStatusResponseSchema = exports_external.object({
12749
+ ok: exports_external.literal(true),
12750
+ status: BrokerStatus
12751
+ });
12752
+ var OkLockResponseSchema = exports_external.object({
12753
+ ok: exports_external.literal(true),
12754
+ locked: exports_external.literal(true)
12755
+ });
12756
+ var OkPutResponseSchema = exports_external.object({
12757
+ ok: exports_external.literal(true),
12758
+ put: exports_external.literal(true),
12759
+ key: exports_external.string()
12760
+ });
12761
+ var OkMintGrantResponseSchema = exports_external.object({
12762
+ ok: exports_external.literal(true),
12763
+ token: exports_external.string(),
12764
+ id: exports_external.string(),
12765
+ expires_at: exports_external.number().nullable()
12766
+ });
12767
+ var GrantMetaSchema = exports_external.object({
12768
+ id: exports_external.string(),
12769
+ agent_slug: exports_external.string(),
12770
+ key_allow: exports_external.array(exports_external.string()),
12771
+ write_allow: exports_external.array(exports_external.string()).default([]),
12772
+ expires_at: exports_external.number().nullable(),
12773
+ created_at: exports_external.number(),
12774
+ description: exports_external.string().nullable()
12775
+ });
12776
+ var OkListGrantsResponseSchema = exports_external.object({
12777
+ ok: exports_external.literal(true),
12778
+ grants: exports_external.array(GrantMetaSchema)
12779
+ });
12780
+ var OkRevokeGrantResponseSchema = exports_external.object({
12781
+ ok: exports_external.literal(true),
12782
+ revoked: exports_external.boolean()
12783
+ });
12784
+ var OkApprovalRequestResponseSchema = exports_external.discriminatedUnion("state", [
12785
+ exports_external.object({
12786
+ ok: exports_external.literal(true),
12787
+ kind: exports_external.literal("approval_request"),
12788
+ state: exports_external.literal("pending"),
12789
+ request_id: exports_external.string(),
12790
+ expires_at: exports_external.number()
12791
+ }),
12792
+ exports_external.object({
12793
+ ok: exports_external.literal(true),
12794
+ kind: exports_external.literal("approval_request"),
12795
+ state: exports_external.literal("rate_limited"),
12796
+ retry_after_ms: exports_external.number()
12797
+ })
12798
+ ]);
12799
+ var ApprovalDecisionMetaSchema = exports_external.object({
12800
+ id: exports_external.string(),
12801
+ agent_unit: exports_external.string(),
12802
+ scope: exports_external.string(),
12803
+ action: exports_external.string(),
12804
+ decision: ApprovalDecisionModeSchema,
12805
+ granted_at: exports_external.number(),
12806
+ granted_by_user_id: exports_external.number(),
12807
+ ttl_expires_at: exports_external.number().nullable(),
12808
+ last_used_at: exports_external.number().nullable(),
12809
+ revoked_at: exports_external.number().nullable(),
12810
+ revoke_reason: exports_external.string().nullable()
12811
+ });
12812
+ var OkApprovalLookupResponseSchema = exports_external.object({
12813
+ ok: exports_external.literal(true),
12814
+ state: exports_external.enum(["granted", "denied", "pending", "expired", "drift_revoked", "no_decision"]),
12815
+ decision: ApprovalDecisionMetaSchema.nullable().optional()
12816
+ });
12817
+ var OkApprovalConsumeResponseSchema = exports_external.object({
12818
+ ok: exports_external.literal(true),
12819
+ consumed: exports_external.boolean(),
12820
+ agent_unit: exports_external.string().optional(),
12821
+ scope: exports_external.string().optional(),
12822
+ action: exports_external.string().optional(),
12823
+ why: exports_external.string().nullable().optional()
12824
+ });
12825
+ var OkApprovalRevokeResponseSchema = exports_external.object({
12826
+ ok: exports_external.literal(true),
12827
+ revoked: exports_external.boolean()
12828
+ });
12829
+ var OkApprovalListResponseSchema = exports_external.object({
12830
+ ok: exports_external.literal(true),
12831
+ decisions: exports_external.array(ApprovalDecisionMetaSchema)
12832
+ });
12833
+ var OkApprovalRecordResponseSchema = exports_external.object({
12834
+ ok: exports_external.literal(true),
12835
+ decision_id: exports_external.string()
12836
+ });
12837
+ var OkApprovalConsumeRecordResponseSchema = exports_external.object({
12838
+ ok: exports_external.literal(true),
12839
+ consumed: exports_external.boolean(),
12840
+ decision_id: exports_external.string().optional(),
12841
+ agent_unit: exports_external.string().optional(),
12842
+ scope: exports_external.string().optional(),
12843
+ action: exports_external.string().optional(),
12844
+ why: exports_external.string().nullable().optional()
12845
+ });
12846
+ var ErrorResponseSchema = exports_external.object({
12847
+ ok: exports_external.literal(false),
12848
+ code: ErrorCode,
12849
+ msg: exports_external.string()
12850
+ });
12851
+ var ResponseSchema = exports_external.union([
12852
+ OkEntryResponseSchema,
12853
+ OkKeysResponseSchema,
12854
+ OkStatusResponseSchema,
12855
+ OkLockResponseSchema,
12856
+ OkPreflightAccessResponseSchema,
12857
+ OkPutResponseSchema,
12858
+ OkMintGrantResponseSchema,
12859
+ OkListGrantsResponseSchema,
12860
+ OkRevokeGrantResponseSchema,
12861
+ OkApprovalRequestResponseSchema,
12862
+ OkApprovalLookupResponseSchema,
12863
+ OkApprovalConsumeResponseSchema,
12864
+ OkApprovalRevokeResponseSchema,
12865
+ OkApprovalListResponseSchema,
12866
+ OkApprovalRecordResponseSchema,
12867
+ OkApprovalConsumeRecordResponseSchema,
12868
+ ErrorResponseSchema
12869
+ ]);
12870
+ function encodeRequest(req) {
12871
+ const json = JSON.stringify(req);
12872
+ if (Buffer.byteLength(json, "utf8") > MAX_FRAME_BYTES) {
12873
+ throw new Error(`Request frame too large (${Buffer.byteLength(json, "utf8")} bytes; max ${MAX_FRAME_BYTES})`);
12874
+ }
12875
+ return json + `
12876
+ `;
12877
+ }
12878
+ function decodeResponse(line) {
12879
+ if (Buffer.byteLength(line, "utf8") > MAX_FRAME_BYTES) {
12880
+ throw new RangeError(`Response frame too large (${Buffer.byteLength(line, "utf8")} bytes; max ${MAX_FRAME_BYTES})`);
12881
+ }
12882
+ const obj = JSON.parse(line);
12883
+ return ResponseSchema.parse(obj);
12884
+ }
12885
+
12886
+ // src/runtime-mode.ts
12887
+ function isDockerRuntime() {
12888
+ return process.env.SWITCHROOM_RUNTIME === "docker";
12889
+ }
12890
+
12891
+ // src/vault/broker/client.ts
12892
+ var DEFAULT_TIMEOUT_MS = 2000;
12893
+ var LEGACY_SOCKET_PATH = join(homedir2(), ".switchroom", "vault-broker.sock");
12894
+ var OPERATOR_SOCKET_PATH = join(homedir2(), ".switchroom", "broker-operator", "sock");
12895
+ function defaultBrokerSocketPath() {
12896
+ if (fs.existsSync(OPERATOR_SOCKET_PATH))
12897
+ return OPERATOR_SOCKET_PATH;
12898
+ if (isDockerRuntime())
12899
+ return OPERATOR_SOCKET_PATH;
12900
+ return LEGACY_SOCKET_PATH;
12901
+ }
12902
+ function resolveBrokerSocketPath(opts) {
12903
+ if (opts?.socket)
12904
+ return opts.socket;
12905
+ const env = process.env.SWITCHROOM_VAULT_BROKER_SOCK;
12906
+ if (env)
12907
+ return env;
12908
+ if (opts?.vaultBrokerSocket)
12909
+ return opts.vaultBrokerSocket;
12910
+ return defaultBrokerSocketPath();
12911
+ }
12912
+ async function rpc(req, opts) {
12913
+ const socketPath = resolveBrokerSocketPath(opts);
12914
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
12915
+ return new Promise((resolve4) => {
12916
+ let settled = false;
12917
+ const settle = (val) => {
12918
+ if (settled)
12919
+ return;
12920
+ settled = true;
12921
+ resolve4(val);
12922
+ };
12923
+ const client = new net.Socket;
12924
+ const timer = setTimeout(() => {
12925
+ client.destroy();
12926
+ settle({ kind: "unreachable", msg: `broker did not respond within ${timeoutMs}ms` });
12927
+ }, timeoutMs);
12928
+ client.on("error", (err) => {
12929
+ clearTimeout(timer);
12930
+ const code = err.code ?? "ERR";
12931
+ let msg;
12932
+ if (code === "ENOENT")
12933
+ msg = "broker socket not found (is the daemon running?)";
12934
+ else if (code === "ECONNREFUSED")
12935
+ msg = "broker socket exists but refused connection";
12936
+ else if (code === "EACCES")
12937
+ msg = "broker socket access denied (wrong UID?)";
12938
+ else
12939
+ msg = `broker connection failed: ${err.message}`;
12940
+ settle({ kind: "unreachable", msg });
12941
+ });
12942
+ let buffer = "";
12943
+ client.on("data", (chunk) => {
12944
+ buffer += chunk.toString("utf8");
12945
+ const newlineIdx = buffer.indexOf(`
12946
+ `);
12947
+ if (newlineIdx !== -1) {
12948
+ const line = buffer.slice(0, newlineIdx).trimEnd();
12949
+ clearTimeout(timer);
12950
+ client.destroy();
12951
+ try {
12952
+ const resp = decodeResponse(line);
12953
+ settle({ kind: "response", resp });
12954
+ } catch (err) {
12955
+ settle({
12956
+ kind: "unreachable",
12957
+ msg: `unparseable broker response: ${err instanceof Error ? err.message : String(err)}`
12958
+ });
12959
+ }
12960
+ }
12961
+ });
12962
+ client.on("connect", () => {
12963
+ try {
12964
+ client.write(encodeRequest(req));
12965
+ } catch (err) {
12966
+ clearTimeout(timer);
12967
+ client.destroy();
12968
+ settle({
12969
+ kind: "unreachable",
12970
+ msg: `failed to send request: ${err instanceof Error ? err.message : String(err)}`
12971
+ });
12972
+ }
12973
+ });
12974
+ client.connect({ path: socketPath });
12975
+ });
12976
+ }
12977
+ async function getViaBrokerStructured(key, opts) {
12978
+ const token = opts?.token;
12979
+ const result = await rpc({ v: 1, op: "get", key, ...token ? { token } : {} }, opts);
12980
+ if (result.kind === "unreachable") {
12981
+ return { kind: "unreachable", msg: result.msg };
12982
+ }
12983
+ const resp = result.resp;
12984
+ if (resp.ok && "entry" in resp) {
12985
+ return { kind: "ok", entry: resp.entry };
12986
+ }
12987
+ if (!resp.ok) {
12988
+ if (resp.code === "UNKNOWN_KEY") {
12989
+ return { kind: "not_found", code: resp.code, msg: resp.msg };
12990
+ }
12991
+ return { kind: "denied", code: resp.code, msg: resp.msg };
12992
+ }
12993
+ return { kind: "unreachable", msg: "unexpected broker response shape" };
12994
+ }
12995
+ async function getViaBroker(key, opts) {
12996
+ const result = await getViaBrokerStructured(key, opts);
12997
+ return result.kind === "ok" ? result.entry : null;
12998
+ }
12999
+
13000
+ // src/agent-scheduler/cheap-cron-wiring.ts
13001
+ var defaultResolveSecret = async (name) => {
13002
+ const entry = await getViaBroker(name);
13003
+ if (!entry)
13004
+ throw new Error(`secret '${name}' unavailable from vault broker`);
13005
+ if (entry.kind !== "string")
13006
+ throw new Error(`secret '${name}' is not a string secret`);
13007
+ return entry.value;
13008
+ };
13009
+ var defaultLookup = async (host) => {
13010
+ try {
13011
+ return (await dnsLookup(host)).address;
13012
+ } catch {
13013
+ return null;
13014
+ }
13015
+ };
13016
+ function buildCheapCronHooks(config, env, deps = {}) {
13017
+ if (!isCheapCronEnabled(env))
13018
+ return;
13019
+ const egress = {
13020
+ hosts: config.cron?.egress?.allowed_hosts ?? [],
13021
+ secretBindings: config.cron?.egress?.secret_bindings ?? {}
13022
+ };
13023
+ const pollState = deps.pollState ?? createFilePollStateStore(env.SWITCHROOM_AGENT_POLL_STATE ?? "/state/agent/poll-state.json");
13024
+ const resolveSecret = deps.resolveSecret ?? defaultResolveSecret;
13025
+ const fetchImpl = deps.fetchImpl ?? fetch;
13026
+ const lookup = deps.lookup ?? defaultLookup;
13027
+ const now = deps.now ?? Date.now;
13028
+ const runPoll = async (spec, prevCursor) => {
13029
+ if (spec.type === "http-diff") {
13030
+ return runHttpDiffPoll(spec, prevCursor, {
13031
+ fetchImpl,
13032
+ resolveSecret,
13033
+ lookup,
13034
+ allow: egress,
13035
+ now
13036
+ });
13037
+ }
13038
+ return { hit: false, baseline: false, error: `poll type '${spec.type}' not yet wired (staged)` };
13039
+ };
13040
+ return { enabled: true, pollState, runPoll };
13041
+ }
13042
+
12192
13043
  // src/telegram/topic-router.ts
12193
13044
  var ALERTS_ALIAS = "alerts";
12194
13045
  var ADMIN_ALIAS = "admin";
@@ -12280,13 +13131,13 @@ function resolveEntryThreadId(entry, channel) {
12280
13131
  }
12281
13132
 
12282
13133
  // src/scheduler/audit.ts
12283
- import { appendFileSync, mkdirSync } from "node:fs";
12284
- import { dirname } from "node:path";
13134
+ import { appendFileSync, mkdirSync as mkdirSync2 } from "node:fs";
13135
+ import { dirname as dirname2 } from "node:path";
12285
13136
  class JsonlAuditSink {
12286
13137
  path;
12287
13138
  constructor(path) {
12288
13139
  this.path = path;
12289
- mkdirSync(dirname(path), { recursive: true, mode: 448 });
13140
+ mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
12290
13141
  }
12291
13142
  recordFire(r) {
12292
13143
  appendFileSync(this.path, JSON.stringify(r) + `
@@ -12312,13 +13163,13 @@ function decideQuotaPreflight(state) {
12312
13163
  }
12313
13164
 
12314
13165
  // src/auth/broker/client.ts
12315
- import * as net from "node:net";
12316
- import { homedir as homedir2 } from "node:os";
13166
+ import * as net2 from "node:net";
13167
+ import { homedir as homedir3 } from "node:os";
12317
13168
  import { randomUUID } from "node:crypto";
12318
- import { join } from "node:path";
13169
+ import { join as join2 } from "node:path";
12319
13170
 
12320
13171
  // src/auth/broker/protocol.ts
12321
- var MAX_FRAME_BYTES = 64 * 1024;
13172
+ var MAX_FRAME_BYTES2 = 64 * 1024;
12322
13173
  var PROTOCOL_VERSION = 1;
12323
13174
  var ProviderNameSchema = exports_external.enum(["anthropic", "google", "microsoft"]);
12324
13175
  var GetCredentialsRequestSchema = exports_external.object({
@@ -12432,7 +13283,7 @@ var ProbeQuotaRequestSchema = exports_external.object({
12432
13283
  accounts: exports_external.array(exports_external.string().min(1)).min(1).max(32),
12433
13284
  timeoutMs: exports_external.number().int().positive().max(60000).optional()
12434
13285
  });
12435
- var RequestSchema = exports_external.discriminatedUnion("op", [
13286
+ var RequestSchema2 = exports_external.discriminatedUnion("op", [
12436
13287
  GetCredentialsRequestSchema,
12437
13288
  ListStateRequestSchema,
12438
13289
  SetActiveRequestSchema,
@@ -12539,25 +13390,25 @@ var SuccessResponseSchema = exports_external.object({
12539
13390
  ok: exports_external.literal(true),
12540
13391
  data: exports_external.unknown()
12541
13392
  });
12542
- var ErrorResponseSchema = exports_external.object({
13393
+ var ErrorResponseSchema2 = exports_external.object({
12543
13394
  v: exports_external.literal(PROTOCOL_VERSION),
12544
13395
  id: exports_external.string(),
12545
13396
  ok: exports_external.literal(false),
12546
13397
  error: ErrorBodySchema
12547
13398
  });
12548
- var ResponseSchema = exports_external.discriminatedUnion("ok", [
13399
+ var ResponseSchema2 = exports_external.discriminatedUnion("ok", [
12549
13400
  SuccessResponseSchema,
12550
- ErrorResponseSchema
13401
+ ErrorResponseSchema2
12551
13402
  ]);
12552
- function encodeRequest(req) {
12553
- const line = JSON.stringify(RequestSchema.parse(req)) + `
13403
+ function encodeRequest2(req) {
13404
+ const line = JSON.stringify(RequestSchema2.parse(req)) + `
12554
13405
  `;
12555
- if (Buffer.byteLength(line, "utf-8") > MAX_FRAME_BYTES) {
12556
- throw new Error(`auth-broker request exceeds MAX_FRAME_BYTES (${MAX_FRAME_BYTES})`);
13406
+ if (Buffer.byteLength(line, "utf-8") > MAX_FRAME_BYTES2) {
13407
+ throw new Error(`auth-broker request exceeds MAX_FRAME_BYTES (${MAX_FRAME_BYTES2})`);
12557
13408
  }
12558
13409
  return line;
12559
13410
  }
12560
- function decodeResponse(line) {
13411
+ function decodeResponse2(line) {
12561
13412
  const trimmed = line.endsWith(`
12562
13413
  `) ? line.slice(0, -1) : line;
12563
13414
  let parsed;
@@ -12566,11 +13417,11 @@ function decodeResponse(line) {
12566
13417
  } catch {
12567
13418
  throw new Error("auth-broker response is not valid JSON");
12568
13419
  }
12569
- return ResponseSchema.parse(parsed);
13420
+ return ResponseSchema2.parse(parsed);
12570
13421
  }
12571
13422
 
12572
13423
  // src/auth/broker/client.ts
12573
- var DEFAULT_TIMEOUT_MS = 5000;
13424
+ var DEFAULT_TIMEOUT_MS2 = 5000;
12574
13425
  function reviveDate(v) {
12575
13426
  if (v == null)
12576
13427
  return null;
@@ -12579,8 +13430,8 @@ function reviveDate(v) {
12579
13430
  const d = new Date(v);
12580
13431
  return Number.isNaN(d.getTime()) ? null : d;
12581
13432
  }
12582
- function operatorSocketPath(home2 = homedir2()) {
12583
- return join(home2, ".switchroom", "state", "auth-broker-operator", "sock");
13433
+ function operatorSocketPath(home2 = homedir3()) {
13434
+ return join2(home2, ".switchroom", "state", "auth-broker-operator", "sock");
12584
13435
  }
12585
13436
  function resolveAuthBrokerSocketPath(opts) {
12586
13437
  if (opts?.socket)
@@ -12621,7 +13472,7 @@ class AuthBrokerClient {
12621
13472
  closed = false;
12622
13473
  constructor(opts = {}) {
12623
13474
  this.socketPath = resolveAuthBrokerSocketPath(opts);
12624
- this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
13475
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2;
12625
13476
  }
12626
13477
  getSocketPath() {
12627
13478
  return this.socketPath;
@@ -12757,7 +13608,7 @@ class AuthBrokerClient {
12757
13608
  if (this.connecting)
12758
13609
  return this.connecting;
12759
13610
  this.connecting = new Promise((resolve4, reject) => {
12760
- const sock = new net.Socket;
13611
+ const sock = new net2.Socket;
12761
13612
  const onError = (err) => {
12762
13613
  sock.removeAllListeners();
12763
13614
  sock.destroy();
@@ -12801,7 +13652,7 @@ class AuthBrokerClient {
12801
13652
  continue;
12802
13653
  let resp;
12803
13654
  try {
12804
- resp = decodeResponse(line);
13655
+ resp = decodeResponse2(line);
12805
13656
  } catch (err) {
12806
13657
  const msg = `unparseable auth-broker response: ${err instanceof Error ? err.message : String(err)}`;
12807
13658
  this.failAll(new AuthBrokerUnreachableError(msg, this.socketPath));
@@ -12839,7 +13690,7 @@ class AuthBrokerClient {
12839
13690
  async send(req) {
12840
13691
  const sock = await this.ensureConnected();
12841
13692
  const id = req.id;
12842
- const frame = encodeRequest(req);
13693
+ const frame = encodeRequest2(req);
12843
13694
  return new Promise((resolve4, reject) => {
12844
13695
  const timer = setTimeout(() => {
12845
13696
  this.pending.delete(id);
@@ -12871,7 +13722,7 @@ class AuthBrokerClient {
12871
13722
  }
12872
13723
 
12873
13724
  // src/agent-scheduler/ipc-client.ts
12874
- import { createConnection } from "node:net";
13725
+ import { createConnection as createConnection2 } from "node:net";
12875
13726
  function createInjectIpcClient(options) {
12876
13727
  const {
12877
13728
  socketPath,
@@ -12879,7 +13730,7 @@ function createInjectIpcClient(options) {
12879
13730
  maxReconnectDelayMs = 30000,
12880
13731
  connectTimeoutMs = 5000,
12881
13732
  log = () => {},
12882
- _connect = (path) => createConnection(path)
13733
+ _connect = (path) => createConnection2(path)
12883
13734
  } = options;
12884
13735
  let socket = null;
12885
13736
  let connected = false;
@@ -13008,16 +13859,16 @@ function createInjectIpcClient(options) {
13008
13859
  import {
13009
13860
  closeSync,
13010
13861
  openSync,
13011
- readFileSync as readFileSync3,
13012
- statSync as statSync2,
13862
+ readFileSync as readFileSync5,
13863
+ statSync as statSync3,
13013
13864
  unlinkSync,
13014
13865
  writeSync,
13015
- mkdirSync as mkdirSync2
13866
+ mkdirSync as mkdirSync3
13016
13867
  } from "node:fs";
13017
- import { dirname as dirname2 } from "node:path";
13868
+ import { dirname as dirname3 } from "node:path";
13018
13869
  function readContainerBootTimeMs() {
13019
13870
  try {
13020
- const stat1 = readFileSync3("/proc/1/stat", "utf8");
13871
+ const stat1 = readFileSync5("/proc/1/stat", "utf8");
13021
13872
  const lastParen = stat1.lastIndexOf(")");
13022
13873
  if (lastParen < 0)
13023
13874
  return null;
@@ -13025,7 +13876,7 @@ function readContainerBootTimeMs() {
13025
13876
  const starttimeTicks = Number(after[19]);
13026
13877
  if (!Number.isFinite(starttimeTicks))
13027
13878
  return null;
13028
- const procStat = readFileSync3("/proc/stat", "utf8");
13879
+ const procStat = readFileSync5("/proc/stat", "utf8");
13029
13880
  const btimeLine = procStat.split(`
13030
13881
  `).find((l) => l.startsWith("btime "));
13031
13882
  if (!btimeLine)
@@ -13043,7 +13894,7 @@ var FRESHNESS_MARGIN_MS = 2000;
13043
13894
  function acquireLock(path, pid = process.pid, options = {}) {
13044
13895
  const bootTimeMs = "containerBootTimeMs" in options ? options.containerBootTimeMs : readContainerBootTimeMs();
13045
13896
  try {
13046
- mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
13897
+ mkdirSync3(dirname3(path), { recursive: true, mode: 448 });
13047
13898
  } catch {}
13048
13899
  for (let attempt = 0;attempt < 2; attempt++) {
13049
13900
  try {
@@ -13060,7 +13911,7 @@ function acquireLock(path, pid = process.pid, options = {}) {
13060
13911
  }
13061
13912
  if (bootTimeMs != null) {
13062
13913
  try {
13063
- const lockMtime = statSync2(path).mtimeMs;
13914
+ const lockMtime = statSync3(path).mtimeMs;
13064
13915
  if (lockMtime < bootTimeMs - FRESHNESS_MARGIN_MS) {
13065
13916
  try {
13066
13917
  unlinkSync(path);
@@ -13071,7 +13922,7 @@ function acquireLock(path, pid = process.pid, options = {}) {
13071
13922
  }
13072
13923
  let holderPid;
13073
13924
  try {
13074
- const raw = readFileSync3(path, "utf8").trim();
13925
+ const raw = readFileSync5(path, "utf8").trim();
13075
13926
  const parsed = Number.parseInt(raw, 10);
13076
13927
  if (Number.isInteger(parsed) && parsed > 0)
13077
13928
  holderPid = parsed;
@@ -13286,12 +14137,12 @@ function findStaleSkippedFires(opts) {
13286
14137
  return out;
13287
14138
  }
13288
14139
  function readRecentFires(jsonlPath) {
13289
- const fs = __require("node:fs");
13290
- if (!fs.existsSync(jsonlPath))
14140
+ const fs2 = __require("node:fs");
14141
+ if (!fs2.existsSync(jsonlPath))
13291
14142
  return [];
13292
14143
  let raw;
13293
14144
  try {
13294
- raw = fs.readFileSync(jsonlPath, "utf8");
14145
+ raw = fs2.readFileSync(jsonlPath, "utf8");
13295
14146
  } catch {
13296
14147
  return [];
13297
14148
  }
@@ -13309,6 +14160,46 @@ function readRecentFires(jsonlPath) {
13309
14160
  }
13310
14161
 
13311
14162
  // src/agent-scheduler/index.ts
14163
+ function templateEscalationPrompt(prompt, diff) {
14164
+ return prompt.replace(/\{\{\s*diff\s*\}\}/g, diff);
14165
+ }
14166
+ function recoverPendingEscalations(opts) {
14167
+ let recovered = 0;
14168
+ for (const entry of opts.entries) {
14169
+ if (entry.kind !== "poll" || !entry.poll)
14170
+ continue;
14171
+ const st = opts.pollState.get(entry.poll.state_key);
14172
+ if (!st?.pendingEscalation)
14173
+ continue;
14174
+ const esc = resolveEscalationRouting(entry, { cheapCronEnabled: opts.cheapCronEnabled });
14175
+ const threadId = resolveEntryThreadId(entry, opts.channel);
14176
+ const startedAt = opts.now();
14177
+ try {
14178
+ const r = dispatchAsInbound({ ...entry, prompt: templateEscalationPrompt(entry.prompt, st.pendingEscalation) }, { chatId: opts.channel.chatId, threadId, now: opts.now, session: esc.session ?? "main", model: esc.cronModel }, opts.dispatcher);
14179
+ if (r.delivered) {
14180
+ opts.pollState.clearPending(entry.poll.state_key);
14181
+ recovered += 1;
14182
+ opts.log(`recovered pending escalation for poll '${entry.poll.state_key}'`);
14183
+ } else {
14184
+ opts.log(`pending escalation for '${entry.poll.state_key}' not delivered — will retry next boot`);
14185
+ }
14186
+ opts.sink?.recordFire({
14187
+ agent: entry.agent,
14188
+ scheduleIndex: entry.scheduleIndex,
14189
+ promptKey: entry.promptKey,
14190
+ exitCode: r.delivered ? 0 : -1,
14191
+ outputSummary: r.delivered ? "recovered pending escalation (boot)" : "pending escalation not delivered (boot)",
14192
+ startedAt,
14193
+ finishedAt: opts.now(),
14194
+ tier: esc.tier === "cheap" ? "cheap" : "main",
14195
+ ...esc.cronModel ? { modelUsed: esc.cronModel } : {}
14196
+ });
14197
+ } catch (e) {
14198
+ opts.log(`pending-escalation recovery failed for '${entry.poll.state_key}': ${e.message}`);
14199
+ }
14200
+ }
14201
+ return recovered;
14202
+ }
13312
14203
  var DEFAULT_MAX_QUOTA_DEFER_ATTEMPTS = 3;
13313
14204
  function defaultQuotaDeferBackoffMs(attempt) {
13314
14205
  return [60000, 180000, 300000][attempt] ?? 300000;
@@ -13358,25 +14249,76 @@ function registerAgentSchedule(opts) {
13358
14249
  return;
13359
14250
  }
13360
14251
  }
14252
+ const cheapEnabled = opts.cheapCron?.enabled ?? false;
14253
+ const routing = resolveCronRouting(entry, { cheapCronEnabled: cheapEnabled });
14254
+ const threadId = resolveEntryThreadId(entry, opts.channel);
14255
+ const record = (fields) => opts.sink.recordFire({
14256
+ agent: entry.agent,
14257
+ scheduleIndex: entry.scheduleIndex,
14258
+ promptKey: entry.promptKey,
14259
+ exitCode: fields.exitCode,
14260
+ outputSummary: fields.summary.slice(0, 200),
14261
+ startedAt,
14262
+ finishedAt: now(),
14263
+ tier: fields.tier,
14264
+ ...fields.modelUsed ? { modelUsed: fields.modelUsed } : {}
14265
+ });
14266
+ if (routing.tier === "poll" && opts.cheapCron && entry.poll) {
14267
+ const stateKey = entry.poll.state_key;
14268
+ const prev = opts.cheapCron.pollState.get(stateKey)?.value;
14269
+ let outcome;
14270
+ try {
14271
+ outcome = await opts.cheapCron.runPoll(entry.poll, prev);
14272
+ } catch (err) {
14273
+ outcome = { hit: false, baseline: false, error: err.message };
14274
+ }
14275
+ if (outcome.error) {
14276
+ record({ exitCode: -3, summary: `poll error: ${outcome.error}`, tier: "poll" });
14277
+ return;
14278
+ }
14279
+ if (outcome.baseline) {
14280
+ opts.cheapCron.pollState.setBaseline(stateKey, outcome.cursor);
14281
+ record({ exitCode: 0, summary: "poll baseline recorded — first run, no escalate", tier: "poll" });
14282
+ return;
14283
+ }
14284
+ if (!outcome.hit) {
14285
+ record({ exitCode: 0, summary: "HEARTBEAT_OK — no change (model-free)", tier: "poll" });
14286
+ return;
14287
+ }
14288
+ opts.cheapCron.pollState.writeAhead(stateKey, outcome.cursor, startedAt, outcome.diff ?? "");
14289
+ const esc = resolveEscalationRouting(entry, { cheapCronEnabled: cheapEnabled });
14290
+ let delivered2 = false;
14291
+ try {
14292
+ const r = dispatchAsInbound({ ...entry, prompt: templateEscalationPrompt(entry.prompt, outcome.diff ?? "") }, { chatId: opts.channel.chatId, threadId, now, session: esc.session ?? "main", model: esc.cronModel }, opts.dispatcher);
14293
+ delivered2 = r.delivered;
14294
+ } catch (err) {
14295
+ record({ exitCode: -1, summary: `escalation dispatch error: ${err.message}`, tier: esc.tier === "cheap" ? "cheap" : "main", modelUsed: esc.cronModel });
14296
+ return;
14297
+ }
14298
+ if (delivered2)
14299
+ opts.cheapCron.pollState.clearPending(stateKey);
14300
+ record({
14301
+ exitCode: delivered2 ? 0 : -1,
14302
+ summary: delivered2 ? `poll hit → escalated (${outcome.diff})` : "poll hit → escalation not delivered",
14303
+ tier: esc.tier === "cheap" ? "cheap" : "main",
14304
+ modelUsed: esc.cronModel
14305
+ });
14306
+ return;
14307
+ }
13361
14308
  let delivered = false;
13362
14309
  let summary = "";
13363
14310
  try {
13364
- const threadId = resolveEntryThreadId(entry, opts.channel);
13365
- const result = dispatchAsInbound(entry, { chatId: opts.channel.chatId, threadId, now }, opts.dispatcher);
14311
+ const result = dispatchAsInbound(entry, { chatId: opts.channel.chatId, threadId, now, session: routing.session ?? "main", model: routing.cronModel }, opts.dispatcher);
13366
14312
  delivered = result.delivered;
13367
14313
  summary = delivered ? "delivered to bridge via gateway" : "no agent client connected — fire dropped";
13368
14314
  } catch (err) {
13369
14315
  summary = `dispatch error: ${err.message}`.slice(0, 200);
13370
14316
  }
13371
- const finishedAt = now();
13372
- opts.sink.recordFire({
13373
- agent: entry.agent,
13374
- scheduleIndex: entry.scheduleIndex,
13375
- promptKey: entry.promptKey,
14317
+ record({
13376
14318
  exitCode: delivered ? 0 : -1,
13377
- outputSummary: summary,
13378
- startedAt,
13379
- finishedAt
14319
+ summary,
14320
+ tier: routing.tier === "cheap" ? "cheap" : "main",
14321
+ modelUsed: routing.cronModel
13380
14322
  });
13381
14323
  };
13382
14324
  const task = opts.cronLib.schedule(entry.cron, () => attemptFire(0));
@@ -13414,7 +14356,7 @@ async function main() {
13414
14356
  }
13415
14357
  const configPath = process.env.SWITCHROOM_CONFIG ?? "/state/config/switchroom.yaml";
13416
14358
  const stateDir = process.env.TELEGRAM_STATE_DIR ?? "/state/agent/telegram";
13417
- const socketPath = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join2(stateDir, "gateway.sock");
14359
+ const socketPath = process.env.SWITCHROOM_GATEWAY_SOCKET ?? join3(stateDir, "gateway.sock");
13418
14360
  const jsonlPath = process.env.SWITCHROOM_AGENT_SCHEDULER_JSONL ?? "/state/agent/scheduler.jsonl";
13419
14361
  const lockPath = process.env.SWITCHROOM_AGENT_SCHEDULER_LOCK ?? "/state/agent/scheduler.lock";
13420
14362
  const lock = acquireLock(lockPath);
@@ -13476,8 +14418,22 @@ async function main() {
13476
14418
  if (missed.length > 0) {
13477
14419
  process.stdout.write(`agent-scheduler: replaying ${missed.length} missed fire(s) ` + `from past ${windowMinutes}min — ` + missed.map((m) => `[idx=${m.entry.scheduleIndex} key=${m.entry.promptKey}]`).join(" ") + `
13478
14420
  `);
14421
+ const cheapEnabledForReplay = isCheapCronEnabled(process.env);
13479
14422
  for (const m of missed) {
13480
14423
  const startedAt = Date.now();
14424
+ if (cheapEnabledForReplay && m.entry.kind === "poll") {
14425
+ sink.recordFire({
14426
+ agent: m.entry.agent,
14427
+ scheduleIndex: m.entry.scheduleIndex,
14428
+ promptKey: m.entry.promptKey,
14429
+ exitCode: 0,
14430
+ outputSummary: "poll replay skipped — re-polls on next tick (stateful cursor)",
14431
+ startedAt,
14432
+ finishedAt: Date.now(),
14433
+ tier: "poll"
14434
+ });
14435
+ continue;
14436
+ }
13481
14437
  const threadId = resolveEntryThreadId(m.entry, channel);
13482
14438
  const result = dispatchAsInbound(m.entry, { chatId: channel.chatId, threadId }, dispatcher);
13483
14439
  sink.recordFire({
@@ -13551,13 +14507,30 @@ Briefly and plainly tell the user these scheduled runs did not ` + "happen so th
13551
14507
  await client.close().catch(() => {});
13552
14508
  }
13553
14509
  } : undefined;
14510
+ const cheapCron = buildCheapCronHooks(config, process.env);
14511
+ if (cheapCron) {
14512
+ const recovered = recoverPendingEscalations({
14513
+ entries,
14514
+ pollState: cheapCron.pollState,
14515
+ dispatcher,
14516
+ channel,
14517
+ cheapCronEnabled: true,
14518
+ now: Date.now,
14519
+ log: (m) => process.stderr.write(`agent-scheduler: ${m}
14520
+ `),
14521
+ sink
14522
+ });
14523
+ process.stdout.write(`agent-scheduler: ${agentName} cheap-cron ENABLED` + (recovered > 0 ? ` (recovered ${recovered} pending escalation(s))` : "") + `
14524
+ `);
14525
+ }
13554
14526
  const tasks = registerAgentSchedule({
13555
14527
  entries,
13556
14528
  channel,
13557
14529
  sink,
13558
14530
  cronLib,
13559
14531
  dispatcher,
13560
- ...quotaGate ? { quotaGate } : {}
14532
+ ...quotaGate ? { quotaGate } : {},
14533
+ ...cheapCron ? { cheapCron } : {}
13561
14534
  });
13562
14535
  process.stdout.write(`agent-scheduler: ${agentName} registered ${tasks.length} task(s); ` + `chat=${channel.chatId} thread=${channel.threadId ?? "(none)"} ` + `socket=${socketPath} jsonl=${jsonlPath}
13563
14536
  `);
@@ -13583,6 +14556,7 @@ export {
13583
14556
  resolveEntryThreadId,
13584
14557
  resolveChannelTarget,
13585
14558
  registerAgentSchedule,
14559
+ recoverPendingEscalations,
13586
14560
  main,
13587
14561
  ipcDispatcher
13588
14562
  };