switchroom 0.16.27 → 0.16.29

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.
@@ -51741,8 +51741,8 @@ import { existsSync, readFileSync } from "node:fs";
51741
51741
  import { dirname, join } from "node:path";
51742
51742
 
51743
51743
  // src/build-info.ts
51744
- var VERSION = "0.16.27";
51745
- var COMMIT_SHA = "68a4feed";
51744
+ var VERSION = "0.16.29";
51745
+ var COMMIT_SHA = "aa0851be";
51746
51746
 
51747
51747
  // src/cli/resolve-version.ts
51748
51748
  function readPackageVersion() {
@@ -78143,7 +78143,7 @@ async function onHermesMessage(ctx, raw) {
78143
78143
  break;
78144
78144
  }
78145
78145
  case "session.status": {
78146
- const sessionId = String(params.session_id ?? "");
78146
+ const sessionId = String(params.session_id ?? ctx.activeSessionId ?? "");
78147
78147
  if (!isKnownSession(config, sessionId)) {
78148
78148
  sendResponse(ctx, rpcErr(id, -32602, `Unknown session: ${sessionId}`));
78149
78149
  break;
@@ -78161,7 +78161,15 @@ async function onHermesMessage(ctx, raw) {
78161
78161
  case "session.create":
78162
78162
  case "session.resume":
78163
78163
  case "session.activate": {
78164
- const sessionId = String(params.session_id ?? params.name ?? "");
78164
+ let sessionId = String(params.session_id ?? params.name ?? "");
78165
+ if (!sessionId && ctx.activeSessionId)
78166
+ sessionId = ctx.activeSessionId;
78167
+ if (!sessionId) {
78168
+ const all = await handleGetAgents(config);
78169
+ const latest = all.sort((a, b) => (b.lastTurnAt ?? 0) - (a.lastTurnAt ?? 0))[0];
78170
+ if (latest)
78171
+ sessionId = latest.name;
78172
+ }
78165
78173
  if (!isKnownSession(config, sessionId)) {
78166
78174
  sendResponse(ctx, rpcErr(id, -32602, `Unknown session: ${sessionId}`));
78167
78175
  break;
@@ -78270,6 +78278,12 @@ async function onHermesMessage(ctx, raw) {
78270
78278
  sendResponse(ctx, rpcOk(id, { ok: true }));
78271
78279
  break;
78272
78280
  }
78281
+ case "pet.info":
78282
+ case "pet.info.meta":
78283
+ case "pet.gallery": {
78284
+ sendResponse(ctx, rpcOk(id, { enabled: false }));
78285
+ break;
78286
+ }
78273
78287
  case "setup.status": {
78274
78288
  sendResponse(ctx, rpcOk(id, { provider_configured: true }));
78275
78289
  break;
@@ -22593,7 +22593,7 @@ import { existsSync as existsSync6, readFileSync as readFileSync4 } from "node:f
22593
22593
  import { dirname as dirname4, join as join2 } from "node:path";
22594
22594
 
22595
22595
  // src/build-info.ts
22596
- var VERSION = "0.16.27";
22596
+ var VERSION = "0.16.29";
22597
22597
 
22598
22598
  // src/cli/resolve-version.ts
22599
22599
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.16.27",
3
+ "version": "0.16.29",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56164,11 +56164,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
56164
56164
  }
56165
56165
 
56166
56166
  // ../src/build-info.ts
56167
- var VERSION = "0.16.27";
56168
- var COMMIT_SHA = "68a4feed";
56169
- var COMMIT_DATE = "2026-06-30T14:02:23+10:00";
56170
- var LATEST_PR = null;
56171
- var COMMITS_AHEAD_OF_TAG = 2;
56167
+ var VERSION = "0.16.29";
56168
+ var COMMIT_SHA = "aa0851be";
56169
+ var COMMIT_DATE = "2026-06-30T06:08:57Z";
56170
+ var LATEST_PR = 2685;
56171
+ var COMMITS_AHEAD_OF_TAG = 0;
56172
56172
 
56173
56173
  // gateway/boot-version.ts
56174
56174
  function formatRelativeAgo(iso) {
@@ -57685,6 +57685,19 @@ var TOPIC_ID = process.env.TELEGRAM_TOPIC_ID ? Number(process.env.TELEGRAM_TOPIC
57685
57685
  var AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === "true";
57686
57686
  var bot = new import_grammy11.Bot(TOKEN);
57687
57687
  installTgPostLogger(bot);
57688
+ var lastGetUpdatesHeartbeatMs = Date.now();
57689
+ bot.api.config.use(async (prev, method, payload, signal) => {
57690
+ try {
57691
+ const result = await prev(method, payload, signal);
57692
+ if (method === "getUpdates")
57693
+ lastGetUpdatesHeartbeatMs = Date.now();
57694
+ return result;
57695
+ } catch (err) {
57696
+ if (method === "getUpdates")
57697
+ lastGetUpdatesHeartbeatMs = Date.now();
57698
+ throw err;
57699
+ }
57700
+ });
57688
57701
  var GRAMMY_VERSION = (() => {
57689
57702
  try {
57690
57703
  const raw = readFileSync37(new URL("../../node_modules/grammy/package.json", import.meta.url), "utf8");
@@ -69756,7 +69769,14 @@ var POLL_HEALTH_THRESHOLD = Number(process.env.SWITCHROOM_POLL_HEALTH_THRESHOLD
69756
69769
  var pollHealthCheck = null;
69757
69770
  if (POLL_HEALTH_INTERVAL_MS > 0) {
69758
69771
  pollHealthCheck = createPollHealthCheck({
69759
- ping: () => bot.api.getMe(),
69772
+ ping: async () => {
69773
+ await bot.api.getMe();
69774
+ const staleMs = Date.now() - lastGetUpdatesHeartbeatMs;
69775
+ const staleThresholdMs = POLL_HEALTH_INTERVAL_MS * POLL_HEALTH_THRESHOLD;
69776
+ if (staleMs > staleThresholdMs) {
69777
+ throw new Error(`getUpdates heartbeat stale: last seen ${Math.round(staleMs / 1000)}s ago (threshold ${Math.round(staleThresholdMs / 1000)}s) \u2014 runner loop frozen`);
69778
+ }
69779
+ },
69760
69780
  onStall: async () => {
69761
69781
  recoverFromPollStall({ agentName: process.env.SWITCHROOM_AGENT_NAME ?? "-" });
69762
69782
  },
@@ -785,6 +785,26 @@ const AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === 'true'
785
785
  const bot = new Bot(TOKEN)
786
786
  installTgPostLogger(bot)
787
787
 
788
+ // ─── getUpdates heartbeat ─────────────────────────────────────────────────
789
+ // Tracks the last time getUpdates completed (success OR error). Used by
790
+ // the poll health check as a secondary stall signal: if getMe succeeds but
791
+ // getUpdates hasn't responded in a while, the grammy runner loop is frozen
792
+ // without the network being down (2026-06-30: clerk/klanker deaf for 2h
793
+ // after a single timeout — getMe stayed green so the getMe-only check
794
+ // never fired). Initialized to now so the first health-check interval
795
+ // doesn't false-positive before the runner makes its first poll.
796
+ let lastGetUpdatesHeartbeatMs = Date.now()
797
+ bot.api.config.use(async (prev, method, payload, signal) => {
798
+ try {
799
+ const result = await prev(method, payload, signal)
800
+ if (method === 'getUpdates') lastGetUpdatesHeartbeatMs = Date.now()
801
+ return result
802
+ } catch (err) {
803
+ if (method === 'getUpdates') lastGetUpdatesHeartbeatMs = Date.now()
804
+ throw err
805
+ }
806
+ })
807
+
788
808
  const GRAMMY_VERSION: string = (() => {
789
809
  try {
790
810
  const raw = readFileSync(new URL('../../node_modules/grammy/package.json', import.meta.url), 'utf8')
@@ -23319,7 +23339,23 @@ const POLL_HEALTH_THRESHOLD = Number(
23319
23339
  let pollHealthCheck: PollHealthCheckHandle | null = null
23320
23340
  if (POLL_HEALTH_INTERVAL_MS > 0) {
23321
23341
  pollHealthCheck = createPollHealthCheck({
23322
- ping: () => bot.api.getMe(),
23342
+ ping: async () => {
23343
+ await bot.api.getMe()
23344
+ // Secondary: if getMe passes but getUpdates hasn't responded in
23345
+ // `threshold × interval` ms, the grammy runner loop is frozen without
23346
+ // the network being down. Throw so the failure counter increments and
23347
+ // stall recovery fires after `failureThreshold` consecutive misses.
23348
+ // (2026-06-30 incident: one getUpdates TimeoutError → runner silently
23349
+ // froze; getMe stayed green; fleet deaf for 2 h until manual restart.)
23350
+ const staleMs = Date.now() - lastGetUpdatesHeartbeatMs
23351
+ const staleThresholdMs = POLL_HEALTH_INTERVAL_MS * POLL_HEALTH_THRESHOLD
23352
+ if (staleMs > staleThresholdMs) {
23353
+ throw new Error(
23354
+ `getUpdates heartbeat stale: last seen ${Math.round(staleMs / 1000)}s ago ` +
23355
+ `(threshold ${Math.round(staleThresholdMs / 1000)}s) — runner loop frozen`,
23356
+ )
23357
+ }
23358
+ },
23323
23359
  onStall: async () => {
23324
23360
  // Exit non-zero → _switchroom_supervise restarts the gateway sidecar
23325
23361
  // with a fresh runner. Never awaits runnerHandle.stop() (it hangs on a
@@ -68,6 +68,70 @@ describe("createPollHealthCheck", () => {
68
68
  hc.stop();
69
69
  });
70
70
 
71
+ it("detects stall when getMe passes but getUpdates heartbeat is stale", async () => {
72
+ // Simulates the 2026-06-30 incident: one getUpdates TimeoutError left the
73
+ // grammy runner loop frozen. getMe kept succeeding so the original
74
+ // getMe-only health check never fired. Fleet was deaf for 2 h.
75
+ // The fix: ping() also checks lastGetUpdatesHeartbeatMs; if stale, throws
76
+ // so the failure counter increments and stall recovery fires.
77
+ const onStall = vi.fn().mockResolvedValue(undefined);
78
+ let tickFn: () => void = () => {};
79
+ let lastGetUpdatesMs = Date.now() - 999_999; // very stale
80
+ const staleThresholdMs = 180_000; // 3 min (threshold × interval)
81
+
82
+ const hc = createPollHealthCheck({
83
+ ping: async () => {
84
+ // getMe succeeds (network fine):
85
+ // (no throw from network layer)
86
+ // heartbeat stale check (mirrors gateway.ts logic):
87
+ const staleMs = Date.now() - lastGetUpdatesMs;
88
+ if (staleMs > staleThresholdMs) {
89
+ throw new Error(
90
+ `getUpdates heartbeat stale: last seen ${Math.round(staleMs / 1000)}s ago — runner loop frozen`,
91
+ );
92
+ }
93
+ },
94
+ onStall,
95
+ failureThreshold: 3,
96
+ setIntervalFn: (fn) => { tickFn = fn; return 1 as unknown as ReturnType<typeof setInterval>; },
97
+ clearIntervalFn: () => {},
98
+ log: () => {},
99
+ });
100
+ hc.start();
101
+ tickFn(); await Promise.resolve();
102
+ tickFn(); await Promise.resolve();
103
+ tickFn(); await Promise.resolve();
104
+ await new Promise((r) => setTimeout(r, 10));
105
+ expect(onStall).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ it("does NOT stall when getUpdates heartbeat is fresh", async () => {
109
+ const onStall = vi.fn().mockResolvedValue(undefined);
110
+ let tickFn: () => void = () => {};
111
+ let lastGetUpdatesMs = Date.now(); // fresh
112
+ const staleThresholdMs = 180_000;
113
+
114
+ const hc = createPollHealthCheck({
115
+ ping: async () => {
116
+ const staleMs = Date.now() - lastGetUpdatesMs;
117
+ if (staleMs > staleThresholdMs) {
118
+ throw new Error("getUpdates heartbeat stale");
119
+ }
120
+ },
121
+ onStall,
122
+ failureThreshold: 3,
123
+ setIntervalFn: (fn) => { tickFn = fn; return 1 as unknown as ReturnType<typeof setInterval>; },
124
+ clearIntervalFn: () => {},
125
+ log: () => {},
126
+ });
127
+ hc.start();
128
+ for (let i = 0; i < 5; i++) {
129
+ tickFn(); await Promise.resolve();
130
+ }
131
+ expect(onStall).not.toHaveBeenCalled();
132
+ hc.stop();
133
+ });
134
+
71
135
  it("stop() cancels the interval", () => {
72
136
  const onStall = vi.fn();
73
137
  let cleared = false;