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.
@@ -23781,7 +23781,7 @@ var init_dist = __esm(() => {
23781
23781
  });
23782
23782
 
23783
23783
  // ../src/config/schema.ts
23784
- var CodeRepoEntrySchema, AgentBindMountSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, SwitchroomConfigSchema;
23784
+ var CodeRepoEntrySchema, AgentBindMountSchema, HttpDiffPollSchema, TelegramReactionsPollSchema, PollSpecSchema, ScheduleEntrySchema, AgentSoulSchema, AgentToolsSchema, AgentMemorySchema, HookEntrySchema, AgentHooksSchema, SubagentSchema, SessionSchema, SessionContinuitySchema, TelegramChannelSchema, ChannelsSchema, TIMEZONE_REGEX, ApproverIdSchema, GoogleWorkspaceTierSchema, GoogleWorkspaceConfigSchema, MicrosoftWorkspaceConfigSchema, NotionWorkspaceConfigSchema, AgentGoogleWorkspaceConfigSchema, AgentMicrosoftWorkspaceConfigSchema, AgentNotionWorkspaceConfigSchema, ReactionsSchema, ReleaseBlock, NetworkIsolationSchema, profileFields, ProfileSchema, _omitExtends, defaultsFields, AgentDefaultsSchema, AgentSchema, TelegramConfigSchema, MemoryBackendConfigSchema, VaultConfigSchema, QuotaConfigSchema, AutoReleaseCheckSchema, HostControlConfigSchema, WebServiceConfigSchema, HostdConfigSchema, CronEgressSchema, CronConfigSchema, SwitchroomConfigSchema;
23785
23785
  var init_schema = __esm(() => {
23786
23786
  init_zod();
23787
23787
  CodeRepoEntrySchema = exports_external.object({
@@ -23794,15 +23794,54 @@ var init_schema = __esm(() => {
23794
23794
  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)."),
23795
23795
  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'.")
23796
23796
  });
23797
+ HttpDiffPollSchema = exports_external.object({
23798
+ type: exports_external.literal("http-diff"),
23799
+ url: exports_external.string().url().describe("Poll target. Host MUST match the operator egress allowlist (\u00a76.1) \u2014 " + "loopback/private/link-local/non-https are rejected; the IP is " + "resolve-then-pinned against DNS-rebind. Not agent-writable without " + "operator commit."),
23800
+ method: exports_external.enum(["GET", "POST"]).default("GET"),
23801
+ headers: exports_external.record(exports_external.string()).optional(),
23802
+ secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault keys this poll may inject into request headers. Each is " + "HOST-PINNED (\u00a76.1): a secret may only be sent to the host it is bound " + "to in operator config, so an approved poll cannot exfil it elsewhere."),
23803
+ diff_jsonpath: exports_external.string().describe("JSONPath into the response; the extracted value is compared to state_key."),
23804
+ state_key: exports_external.string().describe("Key under /state/agent/poll-state.json holding the last-seen value.")
23805
+ });
23806
+ TelegramReactionsPollSchema = exports_external.object({
23807
+ type: exports_external.literal("telegram-reactions"),
23808
+ 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)."),
23809
+ emoji: exports_external.string().min(1).describe("Reaction emoji that marks a message for capture (e.g. \uD83D\uDC68\u200d\uD83D\uDCBB)."),
23810
+ lookback: exports_external.number().int().positive().max(200).default(40),
23811
+ state_key: exports_external.string().describe("Key under poll-state.json holding the last-processed message id.")
23812
+ });
23813
+ PollSpecSchema = exports_external.discriminatedUnion("type", [
23814
+ HttpDiffPollSchema,
23815
+ TelegramReactionsPollSchema
23816
+ ]);
23797
23817
  ScheduleEntrySchema = exports_external.object({
23798
23818
  cron: exports_external.string().describe("Cron expression (e.g., '0 8 * * *')"),
23799
- prompt: exports_external.string().describe("Prompt to send at the scheduled time"),
23800
- model: exports_external.string().optional().describe("DEPRECATED / IGNORED. Pre-v0.8 the singleton scheduler ran each " + "task as an isolated headless invocation and could set --model per " + "task. Post cron-fold-in (v0.8) the fire is injected into the agent's " + "running session, so it always uses the agent's configured model " + "\u2014 this field has no effect. Accepted (optional) only so existing " + "configs keep validating; set the model at the agent level instead. " + "See docs/scheduling.md."),
23819
+ prompt: exports_external.string().describe("Prompt to send at the scheduled time (the escalation prompt when kind=poll; templated with {{diff}})."),
23820
+ 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."),
23821
+ poll: PollSpecSchema.optional().describe("Required iff kind=poll. The declarative poll spec."),
23822
+ model: exports_external.string().optional().describe("Cron model hint. Reactivated by SWITCHROOM_CHEAP_CRON (was DEPRECATED/" + "IGNORED in v0.8). A known-cheap id (sonnet/haiku family) routes the " + "fire to a fresh cheap cron session (Tier 1, `context: fresh`); 'opus', " + "a custom id, or unset routes to the agent's live session (Tier 2, " + "`context: agent`) \u2014 the conservative default that preserves pre-v0.8 " + "behaviour. Note: a live session's model is fixed at launch, so on Tier " + "2 this is informational. See docs/scheduling.md."),
23823
+ context: exports_external.enum(["fresh", "agent"]).optional().describe("Does this cron need the agent, or just a model? 'fresh' \u2192 a minimal-" + "context cheap cron session (Tier 1). 'agent' \u2192 the agent's live " + "session with full persona/memory (Tier 2). Unset \u2192 inferred from " + "`model` (cheap\u2192fresh, else agent). Honoured only when " + "SWITCHROOM_CHEAP_CRON is on."),
23801
23824
  secrets: exports_external.array(exports_external.string().regex(/^[a-zA-Z0-9_\-/]+$/, "Secret key names must contain only alphanumeric characters, underscores, hyphens, and forward slashes")).default([]).describe("Vault key names this cron task may read via the vault-broker daemon. " + "Empty by default \u2014 broker requests for unlisted keys are denied. " + "Note: this is misconfiguration protection (a typo in cron-A doesn't " + "accidentally read cron-B's keys) rather than a security boundary \u2014 " + "anyone who can edit cron scripts can also edit switchroom.yaml, and " + "anyone with the vault passphrase can read the vault file directly. " + "See docs/configuration.md for the full framing."),
23802
23825
  topic: exports_external.union([
23803
23826
  exports_external.string().min(1, "topic alias must be non-empty"),
23804
23827
  exports_external.number().int().positive("topic ID must be a positive integer")
23805
23828
  ]).optional().describe("Forum topic this cron fires into when the owning agent is in " + "supergroup-owned mode (channels.telegram.chat_id set). Either a " + 'string alias resolved against `topic_aliases` (e.g. "planning") ' + "or a numeric topic ID. Falls back to the agent's `default_topic_id` " + "when unset. Ignored for agents in fleet-shared or dm_only mode. " + "Alias-resolution happens at config-load \u2014 typos surface immediately. " + "See docs/rfcs/supergroup-mode.md.")
23829
+ }).superRefine((entry, ctx) => {
23830
+ const kind = entry.kind ?? "prompt";
23831
+ if (kind === "poll" && !entry.poll) {
23832
+ ctx.addIssue({
23833
+ code: exports_external.ZodIssueCode.custom,
23834
+ path: ["poll"],
23835
+ message: "kind: poll requires a `poll` spec (http-diff or telegram-reactions)."
23836
+ });
23837
+ }
23838
+ if (kind === "prompt" && entry.poll) {
23839
+ ctx.addIssue({
23840
+ code: exports_external.ZodIssueCode.custom,
23841
+ path: ["poll"],
23842
+ message: "`poll` is only valid when kind: poll."
23843
+ });
23844
+ }
23806
23845
  });
23807
23846
  AgentSoulSchema = exports_external.object({
23808
23847
  name: exports_external.string().describe("Agent persona name (e.g., 'Coach', 'Sage')"),
@@ -24249,6 +24288,13 @@ var init_schema = __esm(() => {
24249
24288
  config_edit_enabled: exports_external.boolean().default(false).describe("Opt-in toggle for the `config_propose_edit` hostd verb (RFC " + "admin-agent-config-edit \u00a73). Default false \u2014 the verb returns " + "`E_CONFIG_EDIT_DISABLED` until the operator explicitly flips " + "this to true. When true (and once PR 1c lands the apply path), " + "admin agents can propose unified-diff patches against " + "`/state/config/switchroom.yaml`, gated by an operator approval " + "card in the primary chat. Same trust posture as `update_apply` " + "and `agent_restart`: the human-in-the-loop tap is the security " + "boundary, not the agent's judgement."),
24250
24289
  config_edit_rate_per_hour: exports_external.number().int().min(1).max(20).default(3).describe("Per-requesting-agent rate cap for `config_propose_edit` cards " + "(RFC admin-agent-config-edit \u00a75). Default 3 cards/hour; min 1, " + "max 20. Implemented as a sqlite token bucket in PR 1c; the " + "field is wired here in PR 1a so operators can pin it before the " + "limiter is live. Above the cap, the verb returns " + "`E_RATE_LIMITED` without raising a card.")
24251
24290
  });
24291
+ CronEgressSchema = exports_external.object({
24292
+ 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."),
24293
+ secret_bindings: exports_external.record(exports_external.string(), exports_external.string().min(1)).default({}).describe("secretName \u2192 the single host it may be sent to. A poll carrying a secret to any other host is rejected.")
24294
+ });
24295
+ CronConfigSchema = exports_external.object({
24296
+ egress: CronEgressSchema.optional().describe("SSRF/exfil fence for http-diff polls.")
24297
+ });
24252
24298
  SwitchroomConfigSchema = exports_external.object({
24253
24299
  switchroom: exports_external.object({
24254
24300
  version: exports_external.literal(1).describe("Config schema version"),
@@ -24297,7 +24343,8 @@ var init_schema = __esm(() => {
24297
24343
  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."),
24298
24344
  agents: exports_external.record(exports_external.string().regex(/^[a-z0-9][a-z0-9_-]{0,50}$/, {
24299
24345
  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)"
24300
- }), AgentSchema).describe("Map of agent name to agent configuration")
24346
+ }), AgentSchema).describe("Map of agent name to agent configuration"),
24347
+ cron: CronConfigSchema.optional().describe("Cheap-cron settings (docs/rfcs/cheap-cron-sessions.md). Operator-owned " + "egress allowlist + host-pinned secret bindings for Tier-0 http-diff " + "polls (\u00a76.1). Required to enable any http-diff poll; not agent-writable.")
24301
24348
  });
24302
24349
  });
24303
24350
 
@@ -47522,6 +47569,18 @@ function createPendingInboundBuffer(opts = {}) {
47522
47569
  };
47523
47570
  }
47524
47571
 
47572
+ // gateway/cron-session.ts
47573
+ var CRON_IDENTITY_SUFFIX = "-cron";
47574
+ function cronIdentity(agent) {
47575
+ return `${agent}${CRON_IDENTITY_SUFFIX}`;
47576
+ }
47577
+ function isCronIdentity(name) {
47578
+ return typeof name === "string" && name.endsWith(CRON_IDENTITY_SUFFIX);
47579
+ }
47580
+ function resolveInjectTarget(agentName3, meta) {
47581
+ return meta?.session === "cron" ? cronIdentity(agentName3) : agentName3;
47582
+ }
47583
+
47525
47584
  // gateway/obligation-ledger.ts
47526
47585
  class ObligationLedger {
47527
47586
  maxRepresents;
@@ -47578,18 +47637,21 @@ class ObligationLedger {
47578
47637
  return best;
47579
47638
  }
47580
47639
  decideAtIdle(opts) {
47581
- const o = opts != null && opts.graceMs > 0 ? this.oldestEligible(opts.now, opts.graceMs) : this.oldest();
47640
+ const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true);
47641
+ const o = useEligible ? this.oldestEligible(opts.now, opts.graceMs, opts.backgroundWorkActive === true, opts.backgroundGraceMs ?? 0) : this.oldest();
47582
47642
  if (o === undefined)
47583
47643
  return { action: "none" };
47584
47644
  if (o.representCount >= this.maxRepresents)
47585
47645
  return { action: "escalate", obligation: o };
47586
47646
  return { action: "represent", obligation: o };
47587
47647
  }
47588
- oldestEligible(now, graceMs) {
47648
+ oldestEligible(now, graceMs, backgroundWorkActive, backgroundGraceMs) {
47589
47649
  let best;
47590
47650
  for (const o of this.open.values()) {
47591
47651
  if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs)
47592
47652
  continue;
47653
+ if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
47654
+ continue;
47593
47655
  if (best === undefined || o.openedAt < best.openedAt)
47594
47656
  best = o;
47595
47657
  }
@@ -52898,13 +52960,22 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52898
52960
  return false;
52899
52961
  }
52900
52962
  }
52963
+ function readTurnActiveMarkerAgeMs(stateDir, now) {
52964
+ const path = join32(stateDir, TURN_ACTIVE_MARKER_FILE2);
52965
+ try {
52966
+ const st = statSync10(path);
52967
+ return (now ?? Date.now()) - st.mtimeMs;
52968
+ } catch {
52969
+ return null;
52970
+ }
52971
+ }
52901
52972
 
52902
52973
  // ../src/build-info.ts
52903
- var VERSION = "0.14.91";
52904
- var COMMIT_SHA = "e938daab";
52905
- var COMMIT_DATE = "2026-06-09T03:14:21Z";
52906
- var LATEST_PR = 2241;
52907
- var COMMITS_AHEAD_OF_TAG = 0;
52974
+ var VERSION = "0.14.93";
52975
+ var COMMIT_SHA = "87b62902";
52976
+ var COMMIT_DATE = "2026-06-10T08:22:44+10:00";
52977
+ var LATEST_PR = null;
52978
+ var COMMITS_AHEAD_OF_TAG = 3;
52908
52979
 
52909
52980
  // gateway/boot-version.ts
52910
52981
  function formatRelativeAgo(iso) {
@@ -54129,6 +54200,14 @@ var OBLIGATION_ESCALATE_GRACE_MS = (() => {
54129
54200
  const n = Number(raw);
54130
54201
  return Number.isFinite(n) && n >= 0 ? n : 45000;
54131
54202
  })();
54203
+ var OBLIGATION_BACKGROUND_WORK_GRACE_MS = (() => {
54204
+ const raw = process.env.SWITCHROOM_OBLIGATION_BACKGROUND_WORK_GRACE_MS;
54205
+ if (raw == null || raw === "")
54206
+ return 1200000;
54207
+ const n = Number(raw);
54208
+ return Number.isFinite(n) && n >= 0 ? n : 1200000;
54209
+ })();
54210
+ var TURN_ACTIVE_MARKER_FRESH_MS = 90000;
54132
54211
  var AUTOCLASSIFY_MIDTURN_SHADOW = process.env.SWITCHROOM_AUTOCLASSIFY_MIDTURN_SHADOW !== "0";
54133
54212
  var lastAgentOutputAt = new Map;
54134
54213
  var LAST_OUTPUT_MAX_KEYS = 512;
@@ -55646,6 +55725,13 @@ var inboundSpool = STATIC ? undefined : createInboundSpool({
55646
55725
  }
55647
55726
  });
55648
55727
  var pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool });
55728
+ function agentHasInFlightBackgroundWork(now) {
55729
+ if (countRunningWorkers() > 0)
55730
+ return true;
55731
+ const ageMs = readTurnActiveMarkerAgeMs(STATE_DIR, now);
55732
+ return ageMs != null && ageMs < TURN_ACTIVE_MARKER_FRESH_MS;
55733
+ }
55734
+ var lastBgWorkDeferLogMs = 0;
55649
55735
  function obligationSweep() {
55650
55736
  if (!OBLIGATION_LEDGER_ENABLED)
55651
55737
  return;
@@ -55654,10 +55740,23 @@ function obligationSweep() {
55654
55740
  if (turnInFlightForGate())
55655
55741
  return;
55656
55742
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
55657
- const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 ? { now: Date.now(), graceMs: OBLIGATION_ESCALATE_GRACE_MS } : undefined);
55743
+ const now = Date.now();
55744
+ const backgroundWorkActive = OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now);
55745
+ const decision = obligationLedger.decideAtIdle(OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive ? {
55746
+ now,
55747
+ graceMs: OBLIGATION_ESCALATE_GRACE_MS,
55748
+ backgroundWorkActive,
55749
+ backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS
55750
+ } : undefined);
55658
55751
  const o = decision.obligation;
55659
- if (decision.action === "none" || o == null)
55752
+ if (decision.action === "none" || o == null) {
55753
+ if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60000) {
55754
+ lastBgWorkDeferLogMs = now;
55755
+ process.stderr.write(`telegram gateway: obligation sweep deferred \u2014 in-flight autonomous sub-agent work ` + `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60000)}m from receipt)
55756
+ `);
55757
+ }
55660
55758
  return;
55759
+ }
55661
55760
  if (decision.action === "represent") {
55662
55761
  if (pendingInboundBuffer.depth(agent) > 0)
55663
55762
  return;
@@ -55718,6 +55817,16 @@ var ipcServer = createIpcServer({
55718
55817
  onClientRegistered(client3) {
55719
55818
  process.stderr.write(`telegram gateway: bridge registered \u2014 agent=${client3.agentName}
55720
55819
  `);
55820
+ if (isCronIdentity(client3.agentName)) {
55821
+ client3.send({ type: "status", status: "agent_connected" });
55822
+ const pending2 = pendingInboundBuffer.drain(client3.agentName ?? "");
55823
+ for (const m of pending2) {
55824
+ try {
55825
+ client3.send(m);
55826
+ } catch {}
55827
+ }
55828
+ return;
55829
+ }
55721
55830
  const bridgeUpEffects = client3.agentName != null ? shadowEmit({ kind: "bridgeUp", at: Date.now() }) : [];
55722
55831
  client3.send({ type: "status", status: "agent_connected" });
55723
55832
  if (client3.agentName != null) {
@@ -55840,6 +55949,11 @@ var ipcServer = createIpcServer({
55840
55949
  }
55841
55950
  },
55842
55951
  onClientDisconnected(client3) {
55952
+ if (isCronIdentity(client3.agentName)) {
55953
+ process.stderr.write(`telegram gateway: cron-session bridge disconnected \u2014 agent=${client3.agentName}
55954
+ `);
55955
+ return;
55956
+ }
55843
55957
  if (client3.agentName != null) {
55844
55958
  process.stderr.write(`telegram gateway: bridge disconnected \u2014 agent=${client3.agentName}
55845
55959
  `);
@@ -55887,7 +56001,9 @@ var ipcServer = createIpcServer({
55887
56001
  };
55888
56002
  }
55889
56003
  },
55890
- onSessionEvent(_client, msg) {
56004
+ onSessionEvent(client3, msg) {
56005
+ if (isCronIdentity(client3.agentName))
56006
+ return;
55891
56007
  if (msg.activeFile)
55892
56008
  lastSessionActiveFile = msg.activeFile;
55893
56009
  const ev = msg.event;
@@ -55987,7 +56103,9 @@ var ipcServer = createIpcServer({
55987
56103
  firstSeenAt: new Date
55988
56104
  });
55989
56105
  },
55990
- onPtyPartial(_client, msg) {
56106
+ onPtyPartial(client3, msg) {
56107
+ if (isCronIdentity(client3.agentName))
56108
+ return;
55991
56109
  handlePtyPartial(msg.text);
55992
56110
  },
55993
56111
  async onRequestDriveApproval(client3, msg) {
@@ -56212,13 +56330,15 @@ var ipcServer = createIpcServer({
56212
56330
  onInjectInbound(_client, msg) {
56213
56331
  const promptKey = typeof msg.inbound.meta?.prompt_key === "string" ? msg.inbound.meta.prompt_key : "unknown";
56214
56332
  const source = typeof msg.inbound.meta?.source === "string" ? msg.inbound.meta.source : "unknown";
56215
- const delivered = ipcServer.sendToAgent(msg.agentName, msg.inbound);
56216
- if (delivered)
56333
+ const target = resolveInjectTarget(msg.agentName, msg.inbound.meta);
56334
+ const toCron = target !== msg.agentName;
56335
+ const delivered = ipcServer.sendToAgent(target, msg.inbound);
56336
+ if (delivered && !toCron)
56217
56337
  markClaudeBusyForInbound(msg.inbound);
56218
- process.stderr.write(`telegram gateway: inject_inbound agent=${msg.agentName} source=${source} prompt_key=${promptKey} delivered=${delivered}
56338
+ process.stderr.write(`telegram gateway: inject_inbound agent=${msg.agentName} target=${target} source=${source} prompt_key=${promptKey} delivered=${delivered}
56219
56339
  `);
56220
56340
  if (!delivered) {
56221
- pendingInboundBuffer.push(msg.agentName, msg.inbound);
56341
+ pendingInboundBuffer.push(target, msg.inbound);
56222
56342
  }
56223
56343
  },
56224
56344
  onQuotaWallDetected(_client, msg) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Cheap-cron session identity โ€” docs/rfcs/cheap-cron-sessions.md ยง3.3.
3
+ *
4
+ * Rather than rekey the gateway's hardened single-bridge machinery
5
+ * (agentIndex / pendingInboundBuffer / handleRegister, each carrying
6
+ * subtle race fixes), a Tier-1 cron fire is routed to a SECOND bridge
7
+ * that registers under a DERIVED identity `<agent>-cron`. To the IPC
8
+ * layer it is "just another agent", so routing, buffering, disconnect,
9
+ * and heartbeat all work unchanged. The gateway gates its SINGLETON
10
+ * status machinery (shadow bridge-state, boot card, currentTurn /
11
+ * progress card / silence-poke) off the cron identity โ€” which IS the
12
+ * ยง2.4 "cron session is status-silent" requirement, so it is one change,
13
+ * not two.
14
+ *
15
+ * The cron session's bridge sets SWITCHROOM_AGENT_NAME=`<agent>-cron`;
16
+ * the scheduler emits `meta.session='cron'` and the gateway derives the
17
+ * target via `cronIdentity()`. Pure string fns โ€” pinned in cron-session.test.ts.
18
+ */
19
+
20
+ /** Suffix that distinguishes a cron-session bridge from the main agent bridge. */
21
+ export const CRON_IDENTITY_SUFFIX = "-cron";
22
+
23
+ /** Derive the cron-session bridge identity for an agent. */
24
+ export function cronIdentity(agent: string): string {
25
+ return `${agent}${CRON_IDENTITY_SUFFIX}`;
26
+ }
27
+
28
+ /** True iff `name` is a cron-session bridge identity (not a main agent bridge). */
29
+ export function isCronIdentity(name: string | null | undefined): boolean {
30
+ return typeof name === "string" && name.endsWith(CRON_IDENTITY_SUFFIX);
31
+ }
32
+
33
+ /** The real agent name behind a (possibly cron) identity. */
34
+ export function baseAgent(name: string): string {
35
+ return isCronIdentity(name) ? name.slice(0, -CRON_IDENTITY_SUFFIX.length) : name;
36
+ }
37
+
38
+ /**
39
+ * Resolve the IPC routing target for an inject_inbound. When the fire
40
+ * carries `meta.session='cron'` it goes to the derived cron bridge; every
41
+ * other fire (and all of today's callers) goes to the agent unchanged.
42
+ */
43
+ export function resolveInjectTarget(agentName: string, meta: Record<string, string> | undefined): string {
44
+ return meta?.session === "cron" ? cronIdentity(agentName) : agentName;
45
+ }
@@ -287,6 +287,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
287
287
  import { handleRequestMs365Approval } from './ms365-write-approval.js'
288
288
  import { buildDiffPreviewCard } from './diff-preview-card.js'
289
289
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
290
+ import { isCronIdentity, resolveInjectTarget } from './cron-session.js'
290
291
  import {
291
292
  ObligationLedger,
292
293
  buildObligationRepresentInbound,
@@ -428,6 +429,7 @@ import {
428
429
  touchTurnActiveMarker,
429
430
  removeTurnActiveMarker,
430
431
  sweepStaleTurnActiveMarker,
432
+ readTurnActiveMarkerAgeMs,
431
433
  TURN_ACTIVE_MARKER_FILE,
432
434
  } from './turn-active-marker.js'
433
435
  import {
@@ -1467,6 +1469,34 @@ const OBLIGATION_ESCALATE_GRACE_MS = (() => {
1467
1469
  return Number.isFinite(n) && n >= 0 ? n : 45_000
1468
1470
  })()
1469
1471
 
1472
+ // Background-work escalate-grace ceiling. The 45s grace above is far too short
1473
+ // for extended-autonomous sub-agent work: an agent that ack-firsts ("on it")
1474
+ // then delegates to a background worker OR an orphaned foreground sub-agent ends
1475
+ // its FOREGROUND turn in seconds, but the real answer lands minutes later. The
1476
+ // turn-in-flight machine (turn already ended) doesn't see that work, so the
1477
+ // sweep would re-present/escalate a false "โš ๏ธ I may have missed this โ€” re-send"
1478
+ // while the agent is genuinely researching (the gymbro liven-research case,
1479
+ // 2026-06-10). While `agentHasInFlightBackgroundWork()` holds, an open
1480
+ // obligation younger than THIS ceiling (from openedAt) is skipped. Bounded BY
1481
+ // CONSTRUCTION โ€” a hard wall-clock ceiling, so even a stuck/leaked worker can't
1482
+ // suppress escalation forever; the obligation FSM still terminates. This also
1483
+ // preserves the represent budget across a restart that kills the work: with the
1484
+ // false represents suppressed, the hydrated obligation re-presents (resumes the
1485
+ // research) instead of prematurely escalating. Kill switch: =0 โ†’ pre-fix
1486
+ // behaviour (no background-work grace).
1487
+ const OBLIGATION_BACKGROUND_WORK_GRACE_MS = (() => {
1488
+ const raw = process.env.SWITCHROOM_OBLIGATION_BACKGROUND_WORK_GRACE_MS
1489
+ if (raw == null || raw === '') return 20 * 60_000 // 20 min โ€” generous for real research, still bounded
1490
+ const n = Number(raw)
1491
+ return Number.isFinite(n) && n >= 0 ? n : 20 * 60_000
1492
+ })()
1493
+ // Marker-freshness window for the orphaned-foreground signal. The turn-active
1494
+ // marker is touched on every foreground tool_use and on foreground sub-agent
1495
+ // JSONL growth, so an mtime younger than this means a sub-agent is touching it
1496
+ // RIGHT NOW; older โ‡’ the work stopped (or the marker leaked) โ‡’ not active.
1497
+ // Comfortably exceeds the sub-agent poll cadence so it doesn't flap.
1498
+ const TURN_ACTIVE_MARKER_FRESH_MS = 90_000
1499
+
1470
1500
  // โ”€โ”€โ”€ Mid-turn auto-classify (steer-vs-queue), SHADOW mode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1471
1501
  // Today a no-prefix mid-turn message always QUEUES. autoClassifyMidTurnInbound
1472
1502
  // (auto-classify-mid-turn.ts) is the basis for a smarter default using
@@ -5323,24 +5353,72 @@ const pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool })
5323
5353
  // disconnect (disconnect-flush.ts), and by the 300s silence-poke watchdog;
5324
5354
  // (3) the escalation send settles โ€” bounded BY CONSTRUCTION via withDeadline
5325
5355
  // below (grammy has no request timeout, so an unbounded send was the one
5326
- // way an obligation could get stuck OPEN forever โ€” now closed).
5356
+ // way an obligation could get stuck OPEN forever โ€” now closed);
5357
+ // (4) the background-work grace releases โ€” an obligation skipped because
5358
+ // agentHasInFlightBackgroundWork() is true is bounded by
5359
+ // OBLIGATION_BACKGROUND_WORK_GRACE_MS (a hard wall-clock ceiling from
5360
+ // openedAt). Even a permanently-stuck/leaked worker signal cannot suppress
5361
+ // the act past the ceiling, so this adds NO unbounded liveness dependency:
5362
+ // decideAtIdle ignores the work signal once now โ‰ฅ openedAt + ceiling, ฮผ
5363
+ // resumes decreasing, and termination still holds.
5327
5364
  // The only residual liveness assumption is the bridge eventually reconnecting /
5328
5365
  // the process restarting, which the entire gateway's inbound delivery already
5329
5366
  // depends on and which durable spool + boot-replay make self-healing.
5367
+ // True when the agent has in-flight autonomous sub-agent work the turn-in-flight
5368
+ // gate does NOT see: a running background worker (countRunningWorkers โ€” its row
5369
+ // is INSERTed status='running' at dispatch, before the parent turn ends), OR an
5370
+ // orphaned/extended-autonomous FOREGROUND sub-agent that outlived its turn and is
5371
+ // still touching the turn-active marker (#2240; background activity deliberately
5372
+ // does NOT touch the parent marker, so the two signals are complementary).
5373
+ // Used ONLY by the obligation sweep to bound a false escalation during genuine
5374
+ // post-turn work. The caller already established the turn machine is idle (the
5375
+ // `turnInFlightForGate()` early-return), so a fresh marker here means orphaned
5376
+ // sub-agent activity (or a just-ended turn within the freshness window โ€” a
5377
+ // harmless small extra grace, bounded by the ceiling either way).
5378
+ function agentHasInFlightBackgroundWork(now: number): boolean {
5379
+ if (countRunningWorkers() > 0) return true
5380
+ const ageMs = readTurnActiveMarkerAgeMs(STATE_DIR, now)
5381
+ return ageMs != null && ageMs < TURN_ACTIVE_MARKER_FRESH_MS
5382
+ }
5383
+ // Throttle for the background-work defer diagnostic (the 5s sweep would otherwise
5384
+ // log every tick across a multi-minute research window).
5385
+ let lastBgWorkDeferLogMs = 0
5330
5386
  function obligationSweep(): void {
5331
5387
  if (!OBLIGATION_LEDGER_ENABLED) return
5332
5388
  if (!obligationLedger.hasOpen()) return
5333
5389
  if (turnInFlightForGate()) return // a turn is running โ€” let it finish/answer
5334
5390
  const agent = process.env.SWITCHROOM_AGENT_NAME ?? ''
5391
+ const now = Date.now()
5392
+ // Background-work grace: while genuine autonomous sub-agent work is in flight
5393
+ // (a running worker, or an orphaned foreground sub-agent โ€” neither visible to
5394
+ // the turn machine), an obligation younger than the ceiling is NOT re-presented
5395
+ // /escalated. Bounded by OBLIGATION_BACKGROUND_WORK_GRACE_MS so escalation
5396
+ // always eventually fires. =0 disables it.
5397
+ const backgroundWorkActive =
5398
+ OBLIGATION_BACKGROUND_WORK_GRACE_MS > 0 && agentHasInFlightBackgroundWork(now)
5335
5399
  // Grace window: skip an obligation whose handling turn ended < grace ago โ€” its
5336
5400
  // trailing slow/worker answer may still be landing (over-escalation fix).
5337
5401
  const decision = obligationLedger.decideAtIdle(
5338
- OBLIGATION_ESCALATE_GRACE_MS > 0
5339
- ? { now: Date.now(), graceMs: OBLIGATION_ESCALATE_GRACE_MS }
5402
+ OBLIGATION_ESCALATE_GRACE_MS > 0 || backgroundWorkActive
5403
+ ? {
5404
+ now,
5405
+ graceMs: OBLIGATION_ESCALATE_GRACE_MS,
5406
+ backgroundWorkActive,
5407
+ backgroundGraceMs: OBLIGATION_BACKGROUND_WORK_GRACE_MS,
5408
+ }
5340
5409
  : undefined,
5341
5410
  )
5342
5411
  const o = decision.obligation
5343
- if (decision.action === 'none' || o == null) return
5412
+ if (decision.action === 'none' || o == null) {
5413
+ if (backgroundWorkActive && obligationLedger.hasOpen() && now - lastBgWorkDeferLogMs > 60_000) {
5414
+ lastBgWorkDeferLogMs = now
5415
+ process.stderr.write(
5416
+ `telegram gateway: obligation sweep deferred โ€” in-flight autonomous sub-agent work ` +
5417
+ `(${obligationLedger.size()} open, bounded ${Math.round(OBLIGATION_BACKGROUND_WORK_GRACE_MS / 60_000)}m from receipt)\n`,
5418
+ )
5419
+ }
5420
+ return
5421
+ }
5344
5422
  if (decision.action === 'represent') {
5345
5423
  // Re-present goes through the bridge โ†’ buffer. Only the represent path is
5346
5424
  // gated on an empty buffer (let the existing drain run first, avoid
@@ -5474,6 +5552,19 @@ const ipcServer: IpcServer = createIpcServer({
5474
5552
 
5475
5553
  onClientRegistered(client: IpcClient) {
5476
5554
  process.stderr.write(`telegram gateway: bridge registered โ€” agent=${client.agentName}\n`)
5555
+ // Cheap-cron (ยง2.4/ยง3.3): a `<agent>-cron` bridge is the Tier-1 cheap
5556
+ // session. It is STATUS-SILENT โ€” it must NOT drive the gateway's
5557
+ // singleton machinery (shadow bridge-state, warmup, boot card, which all
5558
+ // track the MAIN agent's liveness). Drain any buffered cron fire to it
5559
+ // (so a fire that triggered a lazy spawn lands), then return early.
5560
+ if (isCronIdentity(client.agentName)) {
5561
+ client.send({ type: 'status', status: 'agent_connected' })
5562
+ const pending = pendingInboundBuffer.drain(client.agentName ?? '')
5563
+ for (const m of pending) {
5564
+ try { client.send(m) } catch { /* cron fire drop โ€” best-effort, like today's cron */ }
5565
+ }
5566
+ return
5567
+ }
5477
5568
  // Phase 2b shadow: ONLY emit bridgeUp for the REAL bridge sidecar
5478
5569
  // (with an agent name). Anonymous IPC clients (recall.py, mcp
5479
5570
  // handshakes, etc.) connect briefly without a name and would
@@ -5652,6 +5743,17 @@ const ipcServer: IpcServer = createIpcServer({
5652
5743
  },
5653
5744
 
5654
5745
  onClientDisconnected(client: IpcClient) {
5746
+ // Cheap-cron ยง2.4: a `<agent>-cron` bridge is the status-silent cron
5747
+ // session. Its disconnect (e.g. B3 lazy idle-teardown) must NOT touch
5748
+ // the MAIN agent's singleton state โ€” emitting bridgeDown (the shadow
5749
+ // state is unkeyed) or flushOnAgentDisconnect (flushes the main agent's
5750
+ // active reactions / disposes its progress driver mid-turn) would be the
5751
+ // "premature ๐Ÿ‘" bug for the main session. Symmetric to the cron gate in
5752
+ // onClientRegistered / onSessionEvent / onPtyPartial.
5753
+ if (isCronIdentity(client.agentName)) {
5754
+ process.stderr.write(`telegram gateway: cron-session bridge disconnected โ€” agent=${client.agentName}\n`)
5755
+ return
5756
+ }
5655
5757
  // ONLY log "bridge disconnected" + emit bridgeDown for the REAL
5656
5758
  // bridge sidecar (matching the bridgeUp gate above). Anonymous IPC
5657
5759
  // clients โ€” e.g. recall.py's one-shot legacy update_placeholder
@@ -5722,7 +5824,13 @@ const ipcServer: IpcServer = createIpcServer({
5722
5824
  }
5723
5825
  },
5724
5826
 
5725
- onSessionEvent(_client: IpcClient, msg: SessionEventForward) {
5827
+ onSessionEvent(client: IpcClient, msg: SessionEventForward) {
5828
+ // Cheap-cron ยง2.4: the cron session is status-silent โ€” its session
5829
+ // events must not drive the main agent's progress card, transcript
5830
+ // tail, currentTurn, or silence-poke. Its reply still flows (onToolCall
5831
+ // is not gated). currentTurn was never set for a cron fire (onInjectInbound
5832
+ // skips markClaudeBusy), so the card machinery has nothing to attach to.
5833
+ if (isCronIdentity(client.agentName)) return
5726
5834
  // Track the session-tail's attached file for the proactive-
5727
5835
  // compaction occupancy read (see maybeProactiveCompact).
5728
5836
  if (msg.activeFile) lastSessionActiveFile = msg.activeFile
@@ -5954,7 +6062,10 @@ const ipcServer: IpcServer = createIpcServer({
5954
6062
  * `handlePtyPartial` does its own buffering for the
5955
6063
  * partial-before-enqueue race.
5956
6064
  */
5957
- onPtyPartial(_client: IpcClient, msg: PtyPartialForward) {
6065
+ onPtyPartial(client: IpcClient, msg: PtyPartialForward) {
6066
+ // Cheap-cron ยง2.4: cron session is status-silent โ€” no visible reply
6067
+ // stream from its PTY tail (it would edit a card the main session owns).
6068
+ if (isCronIdentity(client.agentName)) return
5958
6069
  handlePtyPartial(msg.text)
5959
6070
  },
5960
6071
 
@@ -6291,16 +6402,28 @@ const ipcServer: IpcServer = createIpcServer({
6291
6402
  const source = typeof msg.inbound.meta?.source === 'string'
6292
6403
  ? msg.inbound.meta.source
6293
6404
  : 'unknown'
6294
- const delivered = ipcServer.sendToAgent(msg.agentName, msg.inbound)
6295
- if (delivered) markClaudeBusyForInbound(msg.inbound)
6405
+ // Cheap-cron (docs/rfcs/cheap-cron-sessions.md ยง3.3): a Tier-1 fire
6406
+ // carries meta.session='cron' โ†’ route to the derived `<agent>-cron`
6407
+ // bridge (a 2nd interactive Sonnet session in the same container).
6408
+ // Every other fire (and all of today's callers) routes to the agent
6409
+ // unchanged. Route+buffer share the same target so a fire that lands
6410
+ // mid cron-session-spawn buffers under the cron identity and drains to
6411
+ // it on register.
6412
+ const target = resolveInjectTarget(msg.agentName, msg.inbound.meta)
6413
+ const toCron = target !== msg.agentName
6414
+ const delivered = ipcServer.sendToAgent(target, msg.inbound)
6415
+ // Status-silent (ยง2.4): a cron fire must NOT set the MAIN agent's
6416
+ // currentTurn (progress card / silence-poke). The cron session is
6417
+ // fire-and-forget; its reply is its only Telegram surface.
6418
+ if (delivered && !toCron) markClaudeBusyForInbound(msg.inbound)
6296
6419
  process.stderr.write(
6297
- `telegram gateway: inject_inbound agent=${msg.agentName} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
6420
+ `telegram gateway: inject_inbound agent=${msg.agentName} target=${target} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
6298
6421
  )
6299
6422
  // #1150: same buffer-on-failure pattern as vault_grant_approved.
6300
6423
  // Cron fires use this path too โ€” if a cron-driven wake-up lands
6301
6424
  // mid bridge-reconnect, buffer it for the next register.
6302
6425
  if (!delivered) {
6303
- pendingInboundBuffer.push(msg.agentName, msg.inbound)
6426
+ pendingInboundBuffer.push(target, msg.inbound)
6304
6427
  }
6305
6428
  },
6306
6429
 
@@ -187,22 +187,61 @@ export class ObligationLedger {
187
187
  * genuinely-stale one is still acted on while a freshly-ended one waits. Pure
188
188
  * (clock injected via opts.now, mirroring the builder convention). With no opts
189
189
  * (or graceMs<=0) this is the pre-grace behaviour exactly.
190
+ *
191
+ * BACKGROUND-WORK GRACE (opts.backgroundWorkActive): the 45s `graceMs` above is
192
+ * far too short for extended-autonomous sub-agent work โ€” an agent that
193
+ * ack-firsts ("on it") then delegates to a background worker or an orphaned
194
+ * foreground sub-agent ends its FOREGROUND turn in seconds, but the real answer
195
+ * lands minutes later. The in-flight machine (turn already ended) does not see
196
+ * that work, so the sweep would re-present/escalate a false "did I miss this?
197
+ * re-send" while the agent is genuinely researching (the gymbro liven case,
198
+ * 2026-06-10). When the gateway reports `backgroundWorkActive` (a running worker
199
+ * or a freshly-touched turn-active marker), an obligation younger than
200
+ * `backgroundGraceMs` (measured from openedAt) is SKIPPED. Bounded BY
201
+ * CONSTRUCTION: `backgroundGraceMs` is a hard wall-clock ceiling, so even a
202
+ * pathologically-stuck/leaked worker cannot suppress the escalation forever โ€”
203
+ * once openedAt+backgroundGraceMs passes, the obligation is acted on regardless
204
+ * of work state, and the FSM still terminates.
190
205
  */
191
- decideAtIdle(opts?: { now: number; graceMs: number }): LedgerDecision {
192
- const o =
193
- opts != null && opts.graceMs > 0 ? this.oldestEligible(opts.now, opts.graceMs) : this.oldest()
206
+ decideAtIdle(opts?: {
207
+ now: number
208
+ graceMs: number
209
+ backgroundWorkActive?: boolean
210
+ backgroundGraceMs?: number
211
+ }): LedgerDecision {
212
+ const useEligible = opts != null && (opts.graceMs > 0 || opts.backgroundWorkActive === true)
213
+ const o = useEligible
214
+ ? this.oldestEligible(
215
+ opts!.now,
216
+ opts!.graceMs,
217
+ opts!.backgroundWorkActive === true,
218
+ opts!.backgroundGraceMs ?? 0,
219
+ )
220
+ : this.oldest()
194
221
  if (o === undefined) return { action: 'none' }
195
222
  if (o.representCount >= this.maxRepresents) return { action: 'escalate', obligation: o }
196
223
  return { action: 'represent', obligation: o }
197
224
  }
198
225
 
199
- /** The oldest open obligation whose handling turn ended at least `graceMs` ago
200
- * (or never ended โ€” a still-queued obligation has no lastTurnEndedAt and is
201
- * always eligible; it can't have a trailing answer in flight). */
202
- private oldestEligible(now: number, graceMs: number): Obligation | undefined {
226
+ /** The oldest open obligation that is currently ELIGIBLE to act on โ€” i.e. NOT
227
+ * within either grace window:
228
+ * - trailing-answer grace: its handling turn ended < `graceMs` ago (a queued
229
+ * obligation with no lastTurnEndedAt can't have a trailing answer, so it is
230
+ * always eligible on this axis); AND
231
+ * - background-work grace: when `backgroundWorkActive`, it was opened <
232
+ * `backgroundGraceMs` ago (genuine in-flight autonomous work โ€” bounded by
233
+ * the ceiling so a stale/leaked worker can't suppress escalation forever). */
234
+ private oldestEligible(
235
+ now: number,
236
+ graceMs: number,
237
+ backgroundWorkActive: boolean,
238
+ backgroundGraceMs: number,
239
+ ): Obligation | undefined {
203
240
  let best: Obligation | undefined
204
241
  for (const o of this.open.values()) {
205
- if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs) continue // within grace
242
+ if (o.lastTurnEndedAt != null && now - o.lastTurnEndedAt < graceMs) continue // trailing-answer grace
243
+ if (backgroundWorkActive && backgroundGraceMs > 0 && now - o.openedAt < backgroundGraceMs)
244
+ continue // in-flight autonomous work, bounded by the ceiling
206
245
  if (best === undefined || o.openedAt < best.openedAt) best = o
207
246
  }
208
247
  return best