switchroom 0.15.13 → 0.15.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -363,6 +363,7 @@ import type {
363
363
  PtyPartialForward,
364
364
  InboundMessage,
365
365
  InjectInboundMessage,
366
+ SendOutboundMessage,
366
367
  QuotaWallDetectedMessage,
367
368
  PermissionEvent,
368
369
  } from './ipc-protocol.js'
@@ -6546,6 +6547,10 @@ const ipcServer: IpcServer = createIpcServer({
6546
6547
  process.stderr.write(
6547
6548
  `telegram gateway: cron fire fell back to main session (no cron bridge) agent=${msg.agentName} prompt_key=${promptKey}\n`,
6548
6549
  )
6550
+ // #2307 Tier-1 observability: a climbing counter means the cron session is
6551
+ // down (registered then died, or never came up) — the Tier-1 saving is
6552
+ // lost for this fire. Surfaced via the unified runtime-metrics fan-out.
6553
+ emitRuntimeMetric({ kind: 'cron_fell_back_to_main', agent: msg.agentName, prompt_key: promptKey })
6549
6554
  }
6550
6555
  // Status-silent (§2.4): a cron fire delivered to the CRON session must NOT
6551
6556
  // set the MAIN agent's currentTurn. But a fire that LANDED on the main
@@ -6564,6 +6569,47 @@ const ipcServer: IpcServer = createIpcServer({
6564
6569
  }
6565
6570
  },
6566
6571
 
6572
+ // #2307 Tier-0 action tier — a MODEL-FREE outbound post. The agent-scheduler
6573
+ // fires this for a `kind: action` `telegram-message`; the gateway posts the
6574
+ // (already-substituted) text to the agent's OWN chat with NO model: no
6575
+ // inject_inbound, no session wake, no currentTurn mutation. Two fences:
6576
+ // 1. agentName must match this gateway's own SWITCHROOM_AGENT_NAME.
6577
+ // 2. chatId must be an allowlisted chat for this agent (assertAllowedChat).
6578
+ // An action carries no chat target of its own — the scheduler supplies the
6579
+ // agent's own chat — so 2 is belt-and-braces against a malformed/foreign id.
6580
+ onSendOutbound(_client: IpcClient, msg: SendOutboundMessage) {
6581
+ const self = process.env.SWITCHROOM_AGENT_NAME
6582
+ if (self && msg.agentName !== self) {
6583
+ process.stderr.write(
6584
+ `telegram gateway: send_outbound rejected — agent mismatch (${msg.agentName} != ${self})\n`,
6585
+ )
6586
+ return
6587
+ }
6588
+ try {
6589
+ assertAllowedChat(msg.chatId)
6590
+ } catch (err) {
6591
+ process.stderr.write(
6592
+ `telegram gateway: send_outbound rejected — ${(err as Error).message}\n`,
6593
+ )
6594
+ return
6595
+ }
6596
+ const threadId = msg.threadId
6597
+ const parseMode = msg.parseMode === 'text' ? undefined : 'HTML'
6598
+ // allow-raw-bot-api: wrapped in swallowingApiCall (retry policy); thread-aware send.
6599
+ // General topic (thread 1) sends omit message_thread_id per the outbound convention.
6600
+ void swallowingApiCall(
6601
+ () =>
6602
+ bot.api.sendMessage(msg.chatId, msg.text, {
6603
+ ...(parseMode ? { parse_mode: parseMode } : {}),
6604
+ ...(threadId != null && threadId !== 1 ? { message_thread_id: threadId } : {}),
6605
+ }),
6606
+ { chat_id: msg.chatId, verb: 'cron-action-send', ...(threadId != null ? { threadId } : {}) },
6607
+ )
6608
+ process.stderr.write(
6609
+ `telegram gateway: send_outbound agent=${msg.agentName} chat=${msg.chatId} thread=${threadId ?? '-'} len=${msg.text.length}\n`,
6610
+ )
6611
+ },
6612
+
6567
6613
  // The wedge-watchdog detected claude's /rate-limit-options weekly-quota menu
6568
6614
  // (a TUI wall that never produced a 429, so the inference-path auto-fallback
6569
6615
  // never fired). Trigger the SAME fleet auto-fallback the 429 path uses,
@@ -413,6 +413,35 @@ export interface QuotaWallDetectedMessage {
413
413
  resetAt?: number;
414
414
  }
415
415
 
416
+ /**
417
+ * #2307 (Tier-0 action tier) — a MODEL-FREE outbound post. Sent by the
418
+ * in-agent scheduler when a `kind: action` cron's `telegram-message` fires:
419
+ * the gateway posts `text` to the agent's OWN chat with no model involvement
420
+ * (NO `inject_inbound`, NO session wake, NO `currentTurn` mutation). This is
421
+ * the deliberate counterpart to `inject_inbound` — the one ClientToGateway
422
+ * verb that produces an outbound WITHOUT a turn.
423
+ *
424
+ * Trust model: same as `inject_inbound` — the socket is per-agent inside the
425
+ * container, only that-UID processes can connect. `agentName` is validated
426
+ * server-side, and the gateway FENCES `chatId` to the agent's own configured
427
+ * chat (DM allowlist / forum_chat_id) and rejects a foreign chat — an action
428
+ * can never post elsewhere (the action spec carries no chat target; the
429
+ * scheduler supplies the agent's own).
430
+ */
431
+ export interface SendOutboundMessage {
432
+ type: "send_outbound";
433
+ /** Target agent — gateway verifies it matches its own SWITCHROOM_AGENT_NAME. */
434
+ agentName: string;
435
+ /** Agent's own chat id (fenced server-side against the agent's config). */
436
+ chatId: string;
437
+ /** Forum topic thread id (General/unset omitted; 1 is stripped on send). */
438
+ threadId?: number;
439
+ /** Message body — already substitution-resolved by the action engine. */
440
+ text: string;
441
+ /** Telegram parse mode. Defaults to HTML. */
442
+ parseMode?: "html" | "text";
443
+ }
444
+
416
445
  export type ClientToGateway =
417
446
  | RegisterMessage
418
447
  | ToolCallMessage
@@ -428,4 +457,5 @@ export type ClientToGateway =
428
457
  | RequestMs365ApprovalMessage
429
458
  | RequestConfigApprovalMessage
430
459
  | RequestConfigFinalizeMessage
431
- | QuotaWallDetectedMessage;
460
+ | QuotaWallDetectedMessage
461
+ | SendOutboundMessage;
@@ -4,6 +4,7 @@ import type {
4
4
  GatewayToClient,
5
5
  HeartbeatMessage,
6
6
  InjectInboundMessage,
7
+ SendOutboundMessage,
7
8
  QuotaWallDetectedMessage,
8
9
  OperatorEventForward,
9
10
  PermissionRequestForward,
@@ -45,6 +46,15 @@ export interface IpcServerOptions {
45
46
  * inline scheduler simply ignore inject_inbound messages.
46
47
  */
47
48
  onInjectInbound?: (client: IpcClient, msg: InjectInboundMessage) => void;
49
+ /**
50
+ * #2307 Tier-0 action tier — a model-free outbound post. Invoked when the
51
+ * agent-scheduler sibling fires a `kind: action` `telegram-message`. The
52
+ * handler is expected to post `msg.text` to `msg.chatId` (fenced to the
53
+ * agent's own chat) via the locked bot — with NO model, NO inject_inbound,
54
+ * NO session wake. Optional: gateways that don't run the inline scheduler
55
+ * ignore it.
56
+ */
57
+ onSendOutbound?: (client: IpcClient, msg: SendOutboundMessage) => void;
48
58
  /**
49
59
  * The autoaccept-poll wedge-watchdog detected claude's `/rate-limit-options`
50
60
  * weekly-quota menu (no 429 ever reached the gateway). Handler is expected to
@@ -246,6 +256,21 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
246
256
  && typeof inb.meta === "object"
247
257
  && inb.meta !== null;
248
258
  }
259
+ case "send_outbound": {
260
+ // #2307 Tier-0 action tier — a model-free outbound post. Validate the
261
+ // wire shape; the gateway handler fences chatId to the agent's own chat.
262
+ if (typeof m.agentName !== "string"
263
+ || !AGENT_NAME_RE.test(m.agentName as string)) return false;
264
+ if (typeof m.chatId !== "string" || (m.chatId as string).length === 0) return false;
265
+ // text non-empty and bounded — Telegram caps a message at 4096 chars;
266
+ // reject over-long here (defense in depth against a malformed payload).
267
+ if (typeof m.text !== "string" || (m.text as string).length === 0
268
+ || (m.text as string).length > 4096) return false;
269
+ if (m.threadId !== undefined
270
+ && (typeof m.threadId !== "number" || !Number.isInteger(m.threadId as number))) return false;
271
+ if (m.parseMode !== undefined && m.parseMode !== "html" && m.parseMode !== "text") return false;
272
+ return true;
273
+ }
249
274
  case "quota_wall_detected": {
250
275
  // wedge-watchdog detected the /rate-limit-options weekly-quota menu.
251
276
  if (typeof m.agentName !== "string"
@@ -335,6 +360,7 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
335
360
  onOperatorEvent,
336
361
  onPtyPartial,
337
362
  onInjectInbound,
363
+ onSendOutbound,
338
364
  onQuotaWallDetected,
339
365
  onRequestDriveApproval,
340
366
  onRequestMs365Approval,
@@ -444,6 +470,9 @@ export function createIpcServer(options: IpcServerOptions): IpcServer {
444
470
  case "inject_inbound":
445
471
  if (onInjectInbound) onInjectInbound(client, msg as InjectInboundMessage);
446
472
  break;
473
+ case "send_outbound":
474
+ if (onSendOutbound) onSendOutbound(client, msg as SendOutboundMessage);
475
+ break;
447
476
  case "quota_wall_detected":
448
477
  if (onQuotaWallDetected) onQuotaWallDetected(client, msg as QuotaWallDetectedMessage);
449
478
  break;
@@ -137,6 +137,20 @@ export type RuntimeMetricEvent =
137
137
  // executeReply scrub site. The two new sites close that gap.
138
138
  site: 'reply' | 'edit_message' | 'progress_update' | 'answer_stream' | 'stream_reply' | 'turn_flush'
139
139
  }
140
+ /**
141
+ * #2307 Tier-1: a cron fire routed to the `<agent>-cron` cheap session
142
+ * (meta.session=cron) fell back to the MAIN session because the cron bridge
143
+ * wasn't registered (wedged boot, crashed session, or hot-added cron with no
144
+ * live session yet). Each occurrence means the Tier-1 saving was NOT realised
145
+ * for that fire — a climbing counter is the runtime signal that a cron
146
+ * session is down (the doctor check catches a permanently-wedged one at boot;
147
+ * this catches a session that registered then died).
148
+ */
149
+ | {
150
+ kind: 'cron_fell_back_to_main'
151
+ agent: string
152
+ prompt_key: string
153
+ }
140
154
 
141
155
  /**
142
156
  * The JSONL sink lives under the runtime state dir so it's per-agent
@@ -0,0 +1,21 @@
1
+ /**
2
+ * #2307 Tier-1 — the cron-session bridge writes its liveness file to a DISTINCT
3
+ * path so a live `<agent>-cron` bridge can't mask a dead MAIN bridge in the
4
+ * dashboard/doctor probe (RISK #2). The bridge is an IIFE (can't instantiate
5
+ * in-process), so this pins the wiring by source-read: the liveness file path
6
+ * honours `SWITCHROOM_BRIDGE_ALIVE_PATH`, falling back to the canonical
7
+ * STATE_DIR/.bridge-alive (unchanged for the main bridge).
8
+ */
9
+ import { describe, it, expect } from "vitest";
10
+ import { readFileSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+
13
+ const bridgeSrc = readFileSync(resolve(__dirname, "..", "bridge", "bridge.ts"), "utf-8");
14
+
15
+ describe("bridge liveness path override (#2307 Tier-1)", () => {
16
+ it("honours SWITCHROOM_BRIDGE_ALIVE_PATH, falling back to STATE_DIR/.bridge-alive", () => {
17
+ expect(bridgeSrc).toMatch(
18
+ /livenessFilePath:\s*process\.env\.SWITCHROOM_BRIDGE_ALIVE_PATH\s*\?\?\s*join\(STATE_DIR,\s*["']\.bridge-alive["']\)/,
19
+ );
20
+ });
21
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Validation contract for the `send_outbound` IPC verb (#2307 Tier-0 action
3
+ * tier) — the MODEL-FREE outbound the agent-scheduler sends for a `kind: action`
4
+ * telegram-message. A rogue process on the same UDS must not inject a malformed
5
+ * payload: agentName required + name-shaped, chatId + text required non-empty,
6
+ * threadId (optional) an integer, parseMode (optional) html|text.
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import { validateClientMessage } from "../gateway/ipc-server.js";
10
+
11
+ const base = { type: "send_outbound", agentName: "clerk", chatId: "12345", text: "Daily heartbeat" };
12
+
13
+ describe("validateClientMessage — send_outbound", () => {
14
+ it("accepts a well-formed minimal message", () => {
15
+ expect(validateClientMessage(base)).toBe(true);
16
+ });
17
+
18
+ it("accepts threadId + parseMode", () => {
19
+ expect(validateClientMessage({ ...base, threadId: 7, parseMode: "html" })).toBe(true);
20
+ expect(validateClientMessage({ ...base, threadId: 1, parseMode: "text" })).toBe(true);
21
+ });
22
+
23
+ it("rejects a missing / non-string / malformed agentName", () => {
24
+ expect(validateClientMessage({ ...base, agentName: undefined })).toBe(false);
25
+ expect(validateClientMessage({ ...base, agentName: 123 })).toBe(false);
26
+ expect(validateClientMessage({ ...base, agentName: "" })).toBe(false);
27
+ expect(validateClientMessage({ ...base, agentName: "../etc" })).toBe(false);
28
+ expect(validateClientMessage({ ...base, agentName: "Clerk UPPER" })).toBe(false);
29
+ });
30
+
31
+ it("rejects a missing / empty chatId", () => {
32
+ expect(validateClientMessage({ ...base, chatId: undefined })).toBe(false);
33
+ expect(validateClientMessage({ ...base, chatId: "" })).toBe(false);
34
+ expect(validateClientMessage({ ...base, chatId: 123 })).toBe(false);
35
+ });
36
+
37
+ it("rejects a missing / empty / over-long text", () => {
38
+ expect(validateClientMessage({ ...base, text: undefined })).toBe(false);
39
+ expect(validateClientMessage({ ...base, text: "" })).toBe(false);
40
+ expect(validateClientMessage({ ...base, text: 5 })).toBe(false);
41
+ expect(validateClientMessage({ ...base, text: "x".repeat(4096) })).toBe(true); // at the cap
42
+ expect(validateClientMessage({ ...base, text: "x".repeat(4097) })).toBe(false); // over Telegram's limit
43
+ });
44
+
45
+ it("rejects a non-integer threadId", () => {
46
+ expect(validateClientMessage({ ...base, threadId: 1.5 })).toBe(false);
47
+ expect(validateClientMessage({ ...base, threadId: "1" })).toBe(false);
48
+ });
49
+
50
+ it("rejects an unknown parseMode", () => {
51
+ expect(validateClientMessage({ ...base, parseMode: "markdown" })).toBe(false);
52
+ expect(validateClientMessage({ ...base, parseMode: 1 })).toBe(false);
53
+ });
54
+ });
@@ -82,6 +82,15 @@ describe('runtime-metrics — JSONL sink', () => {
82
82
  expect(parsed.ended_via).toBe('reply')
83
83
  })
84
84
 
85
+ it('cron_fell_back_to_main carries agent + prompt_key (#2307 Tier-1)', () => {
86
+ emitRuntimeMetric({ kind: 'cron_fell_back_to_main', agent: 'clerk', prompt_key: 'abc123' })
87
+ const parsed = JSON.parse(readFileSync(metricsPath, 'utf-8').trim())
88
+ expect(parsed.kind).toBe('cron_fell_back_to_main')
89
+ expect(parsed.agent).toBe('clerk')
90
+ expect(parsed.prompt_key).toBe('abc123')
91
+ expect(typeof parsed.ts).toBe('number')
92
+ })
93
+
85
94
  it('appends — does not overwrite — across calls', () => {
86
95
  for (let i = 0; i < 5; i++) {
87
96
  emitRuntimeMetric({
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Structural wiring guard for the model-free `send_outbound` verb (#2307).
3
+ *
4
+ * The gateway handler lives in the createIpcServer({...}) IIFE block, which
5
+ * can't be imported in a unit test (same constraint as the inject_inbound /
6
+ * linear-activity wiring tests), so the load-bearing invariants are pinned by
7
+ * reading the source:
8
+ * 1. ipc-server routes `send_outbound` → onSendOutbound.
9
+ * 2. the gateway handler fences agent (agentName === self) AND chat
10
+ * (assertAllowedChat) before sending.
11
+ * 3. it is MODEL-FREE: never markClaudeBusy / currentTurn / inject — a
12
+ * send_outbound must not wake a turn (the whole point of Tier-0).
13
+ */
14
+ import { describe, it, expect } from "vitest";
15
+ import { readFileSync } from "node:fs";
16
+
17
+ const gw = readFileSync(new URL("../gateway/gateway.ts", import.meta.url), "utf8");
18
+ const ipcServer = readFileSync(new URL("../gateway/ipc-server.ts", import.meta.url), "utf8");
19
+
20
+ describe("send_outbound — ipc-server routing", () => {
21
+ it("validates the verb and dispatches to onSendOutbound", () => {
22
+ expect(ipcServer).toMatch(/case "send_outbound":/);
23
+ expect(ipcServer).toMatch(/onSendOutbound\(client, msg as SendOutboundMessage\)/);
24
+ // the validator has a dedicated arm.
25
+ expect(ipcServer).toMatch(/case "send_outbound": \{/);
26
+ });
27
+ });
28
+
29
+ describe("send_outbound — gateway handler invariants", () => {
30
+ // Isolate the handler body for the assertions below.
31
+ const start = gw.indexOf("onSendOutbound(_client: IpcClient, msg: SendOutboundMessage)");
32
+ // Bound the slice at the START of the NEXT handler so the whole onSendOutbound
33
+ // body is covered regardless of how it grows (no fixed char window to outgrow),
34
+ // without bleeding into a sibling handler.
35
+ const next = gw.indexOf("onQuotaWallDetected(", start);
36
+ const handler = gw.slice(start, next > start ? next : start + 2600);
37
+
38
+ it("the handler exists", () => {
39
+ expect(start).toBeGreaterThan(0);
40
+ });
41
+
42
+ it("fences the agent (agentName must match this gateway's own)", () => {
43
+ expect(handler).toMatch(/msg\.agentName !== self/);
44
+ });
45
+
46
+ it("fences the chat to the agent's own allowlist (assertAllowedChat)", () => {
47
+ expect(handler).toMatch(/assertAllowedChat\(msg\.chatId\)/);
48
+ });
49
+
50
+ it("is MODEL-FREE — never wakes a turn (no markClaudeBusy / currentTurn / inject)", () => {
51
+ expect(handler).not.toMatch(/markClaudeBusy/);
52
+ expect(handler).not.toMatch(/currentTurn/);
53
+ expect(handler).not.toMatch(/sendToAgent/);
54
+ expect(handler).not.toMatch(/inject/i);
55
+ });
56
+
57
+ it("sends via the wrapped retry path (not a raw bot.api outside swallowingApiCall)", () => {
58
+ expect(handler).toMatch(/swallowingApiCall\(/);
59
+ expect(handler).toMatch(/bot\.api\.sendMessage\(msg\.chatId, msg\.text/);
60
+ // General topic (thread 1) is stripped on send, per the outbound convention.
61
+ expect(handler).toMatch(/threadId !== 1/);
62
+ });
63
+ });