switchroom 0.12.15 → 0.12.16

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.
@@ -47242,8 +47242,8 @@ var {
47242
47242
  } = import__.default;
47243
47243
 
47244
47244
  // src/build-info.ts
47245
- var VERSION = "0.12.15";
47246
- var COMMIT_SHA = "dc508a92";
47245
+ var VERSION = "0.12.16";
47246
+ var COMMIT_SHA = "b30ce83a";
47247
47247
 
47248
47248
  // src/cli/agent.ts
47249
47249
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.15",
3
+ "version": "0.12.16",
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": {
@@ -43366,6 +43366,15 @@ function redeliverBufferedInbound(buffer, agent, send) {
43366
43366
  }
43367
43367
  return { drained: pending.length, redelivered, rebuffered };
43368
43368
  }
43369
+ function idleDrainTick(buffer, agent, isBridgeAlive, send) {
43370
+ if (!agent)
43371
+ return null;
43372
+ if (buffer.depth(agent) === 0)
43373
+ return null;
43374
+ if (!isBridgeAlive())
43375
+ return null;
43376
+ return redeliverBufferedInbound(buffer, agent, send);
43377
+ }
43369
43378
  function createPendingInboundBuffer(opts = {}) {
43370
43379
  const cap = opts.capPerAgent ?? DEFAULT_PENDING_INBOUND_CAP;
43371
43380
  const log = opts.log ?? ((line) => process.stderr.write(line));
@@ -46768,11 +46777,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
46768
46777
  }
46769
46778
 
46770
46779
  // ../src/build-info.ts
46771
- var VERSION = "0.12.15";
46772
- var COMMIT_SHA = "dc508a92";
46773
- var COMMIT_DATE = "2026-05-19T07:24:41Z";
46774
- var LATEST_PR = 1547;
46775
- var COMMITS_AHEAD_OF_TAG = 22;
46780
+ var VERSION = "0.12.16";
46781
+ var COMMIT_SHA = "b30ce83a";
46782
+ var COMMIT_DATE = "2026-05-19T08:46:47Z";
46783
+ var LATEST_PR = 1550;
46784
+ var COMMITS_AHEAD_OF_TAG = 25;
46776
46785
 
46777
46786
  // gateway/boot-version.ts
46778
46787
  function formatRelativeAgo(iso) {
@@ -48777,6 +48786,20 @@ ${reminder}
48777
48786
  log: (msg) => process.stderr.write(`telegram gateway: ipc \u2014 ${msg}
48778
48787
  `)
48779
48788
  });
48789
+ var IDLE_DRAIN_INTERVAL_MS = 5000;
48790
+ if (!STATIC) {
48791
+ setInterval(() => {
48792
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
48793
+ const r = idleDrainTick(pendingInboundBuffer, selfAgent, () => {
48794
+ const c = ipcServer.getClient(selfAgent);
48795
+ return c != null && c.isAlive();
48796
+ }, (m) => ipcServer.sendToAgent(selfAgent, m));
48797
+ if (r != null && r.redelivered > 0) {
48798
+ process.stderr.write(`telegram gateway: idle-drain flushed ${r.redelivered}/${r.drained} buffered inbound for ${selfAgent}${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ""}
48799
+ `);
48800
+ }
48801
+ }, IDLE_DRAIN_INTERVAL_MS).unref();
48802
+ }
48780
48803
  var ALLOWED_TOOLS = new Set([
48781
48804
  "reply",
48782
48805
  "stream_reply",
@@ -245,7 +245,7 @@ import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
245
245
  import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js'
246
246
  import { handleRequestDriveApproval } from './drive-write-approval.js'
247
247
  import { buildDiffPreviewCard } from './diff-preview-card.js'
248
- import { createPendingInboundBuffer, redeliverBufferedInbound } from './pending-inbound-buffer.js'
248
+ import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
249
249
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
250
250
  import {
251
251
  buildVaultGrantApprovedInbound,
@@ -3272,6 +3272,48 @@ const ipcServer: IpcServer = createIpcServer({
3272
3272
  log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
3273
3273
  })
3274
3274
 
3275
+ // ─── Opportunistic idle-drain of pendingInboundBuffer ─────────────────────
3276
+ // pendingInboundBuffer otherwise drains only on (a) bridge re-register
3277
+ // (onClientRegistered) or (b) the silence-poke framework fallback
3278
+ // clearing a wedged turn (#1546). NEITHER fires when a message is
3279
+ // buffered during a bridge-IPC flap that then settles with no
3280
+ // subsequent clean re-register AND claude is idle (no active turn →
3281
+ // silence-poke never arms). The message orphans until a manual restart
3282
+ // (finn, 2026-05-19 — buffered "verify with mff-query.py cashflow"
3283
+ // while idle; last `bridge registered` predated the buffer push, so
3284
+ // onClientRegistered's drain never ran for it).
3285
+ //
3286
+ // This is the third drain trigger. It's gated to be zero-cost and
3287
+ // zero-churn: skip entirely when nothing is buffered (one Map.get, no
3288
+ // log) or when the bridge isn't alive (exactly sendToAgent's own
3289
+ // guard — so we never drain into a dead bridge and re-buffer/log-spin).
3290
+ // Only when there IS a buffered message AND a live bridge do we reuse
3291
+ // the #1546 `redeliverBufferedInbound` (lossless: re-buffers any
3292
+ // per-message miss). A message delivered while a turn is active is
3293
+ // queued normally by the bridge — same as a live arrival, not lost.
3294
+ const IDLE_DRAIN_INTERVAL_MS = 5000
3295
+ if (!STATIC) {
3296
+ setInterval(() => {
3297
+ const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
3298
+ const r = idleDrainTick(
3299
+ pendingInboundBuffer,
3300
+ selfAgent,
3301
+ () => {
3302
+ const c = ipcServer.getClient(selfAgent)
3303
+ return c != null && c.isAlive()
3304
+ },
3305
+ (m) => ipcServer.sendToAgent(selfAgent, m),
3306
+ )
3307
+ if (r != null && r.redelivered > 0) {
3308
+ process.stderr.write(
3309
+ `telegram gateway: idle-drain flushed ${r.redelivered}/${r.drained} ` +
3310
+ `buffered inbound for ${selfAgent}` +
3311
+ `${r.rebuffered > 0 ? ` (${r.rebuffered} re-buffered)` : ''}\n`,
3312
+ )
3313
+ }
3314
+ }, IDLE_DRAIN_INTERVAL_MS).unref()
3315
+ }
3316
+
3275
3317
  // ─── Tool execution ──────────────────────────────────────────────────────
3276
3318
 
3277
3319
  /** Allowlisted tool names that bridges may invoke via IPC. Prevents a rogue
@@ -93,6 +93,38 @@ export function redeliverBufferedInbound(
93
93
  return { drained: pending.length, redelivered, rebuffered }
94
94
  }
95
95
 
96
+ /**
97
+ * One opportunistic idle-drain tick. The third drain trigger, beside
98
+ * `onClientRegistered` (bridge re-register) and the silence-poke
99
+ * wedge-clear (#1546). Closes the orphan gap those two miss: a message
100
+ * buffered during a bridge-IPC flap that settles with no subsequent
101
+ * clean re-register while claude is idle (no turn → no silence-poke)
102
+ * — it would otherwise sit until a manual restart (finn, 2026-05-19).
103
+ *
104
+ * Gated to be zero-cost / zero-churn so it can run on a short timer:
105
+ * - empty buffer → return null (one Map.get, NO drain, NO log)
106
+ * - bridge not alive → return null (never drain into a dead bridge,
107
+ * which would re-buffer+log-spin every tick; onClientRegistered
108
+ * will drain on the eventual reconnect instead)
109
+ * - otherwise → `redeliverBufferedInbound` (lossless: re-buffers any
110
+ * per-message miss). A message delivered mid-turn is queued
111
+ * normally by the bridge, same as a live arrival — not lost.
112
+ *
113
+ * Returns the redeliver counts only when it actually ran, else null
114
+ * (so the caller logs only on a real flush).
115
+ */
116
+ export function idleDrainTick(
117
+ buffer: PendingInboundBuffer,
118
+ agent: string,
119
+ isBridgeAlive: () => boolean,
120
+ send: (msg: InboundMessage) => boolean,
121
+ ): { drained: number; redelivered: number; rebuffered: number } | null {
122
+ if (!agent) return null
123
+ if (buffer.depth(agent) === 0) return null
124
+ if (!isBridgeAlive()) return null
125
+ return redeliverBufferedInbound(buffer, agent, send)
126
+ }
127
+
96
128
  export function createPendingInboundBuffer(
97
129
  opts: PendingInboundBufferOptions = {},
98
130
  ): PendingInboundBuffer {
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect } from 'vitest'
10
- import { createPendingInboundBuffer, redeliverBufferedInbound, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
10
+ import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
11
11
  import type { InboundMessage } from '../gateway/ipc-protocol.js'
12
12
 
13
13
  function inbound(source: string, ts = Date.now()): InboundMessage {
@@ -200,3 +200,50 @@ describe('redeliverBufferedInbound — wedge-clear self-heal (fleet-update incid
200
200
  expect(buf.depth('clerk')).toBe(1) // untouched
201
201
  })
202
202
  })
203
+
204
+ describe('idleDrainTick — the 3rd drain trigger (finn orphan gap, 2026-05-19)', () => {
205
+ it('no-op (returns null, no send) when the buffer is empty', () => {
206
+ const buf = createPendingInboundBuffer({ log: () => {} })
207
+ let sent = 0
208
+ const r = idleDrainTick(buf, 'finn', () => true, () => { sent++; return true })
209
+ expect(r).toBeNull()
210
+ expect(sent).toBe(0)
211
+ })
212
+
213
+ it('no-op (returns null) when bridge is NOT alive — never drains into a dead bridge', () => {
214
+ const buf = createPendingInboundBuffer({ log: () => {} })
215
+ buf.push('finn', inbound('user', 1))
216
+ let sent = 0
217
+ const r = idleDrainTick(buf, 'finn', () => false, () => { sent++; return true })
218
+ expect(r).toBeNull()
219
+ expect(sent).toBe(0)
220
+ expect(buf.depth('finn')).toBe(1) // untouched — onClientRegistered will get it on reconnect
221
+ })
222
+
223
+ it('flushes the buffer when bridge is alive AND something is buffered (the finn fix)', () => {
224
+ const buf = createPendingInboundBuffer({ log: () => {} })
225
+ buf.push('finn', inbound('user', 2013)) // the orphaned "verify with mff-query.py" class
226
+ const seen: number[] = []
227
+ const r = idleDrainTick(buf, 'finn', () => true, (m) => { seen.push(m.messageId as number); return true })
228
+ expect(r).toEqual({ drained: 1, redelivered: 1, rebuffered: 0 })
229
+ expect(seen).toEqual([2013])
230
+ expect(buf.depth('finn')).toBe(0)
231
+ })
232
+
233
+ it('is lossless — a delivery miss re-buffers, returns null on empty agent', () => {
234
+ const buf = createPendingInboundBuffer({ log: () => {} })
235
+ buf.push('finn', inbound('user', 1))
236
+ const r = idleDrainTick(buf, 'finn', () => true, () => false)
237
+ expect(r).toEqual({ drained: 1, redelivered: 0, rebuffered: 1 })
238
+ expect(buf.depth('finn')).toBe(1) // nothing lost
239
+ expect(idleDrainTick(buf, '', () => true, () => true)).toBeNull() // empty agent guard
240
+ })
241
+
242
+ it('checks depth BEFORE isBridgeAlive — empty buffer never probes the bridge', () => {
243
+ const buf = createPendingInboundBuffer({ log: () => {} })
244
+ let probed = false
245
+ const r = idleDrainTick(buf, 'finn', () => { probed = true; return true }, () => true)
246
+ expect(r).toBeNull()
247
+ expect(probed).toBe(false) // cheap path: Map.get only, no bridge probe, no log
248
+ })
249
+ })