switchroom 0.13.30 → 0.13.31

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.
@@ -47436,8 +47436,8 @@ var {
47436
47436
  } = import__.default;
47437
47437
 
47438
47438
  // src/build-info.ts
47439
- var VERSION = "0.13.30";
47440
- var COMMIT_SHA = "c544689a";
47439
+ var VERSION = "0.13.31";
47440
+ var COMMIT_SHA = "061eead1";
47441
47441
 
47442
47442
  // src/cli/agent.ts
47443
47443
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.30",
3
+ "version": "0.13.31",
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": {
@@ -48504,10 +48504,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48504
48504
  }
48505
48505
 
48506
48506
  // ../src/build-info.ts
48507
- var VERSION = "0.13.30";
48508
- var COMMIT_SHA = "c544689a";
48509
- var COMMIT_DATE = "2026-05-24T12:56:26Z";
48510
- var LATEST_PR = 1734;
48507
+ var VERSION = "0.13.31";
48508
+ var COMMIT_SHA = "061eead1";
48509
+ var COMMIT_DATE = "2026-05-24T13:21:09Z";
48510
+ var LATEST_PR = 1736;
48511
48511
  var COMMITS_AHEAD_OF_TAG = 0;
48512
48512
 
48513
48513
  // gateway/boot-version.ts
@@ -49490,6 +49490,22 @@ function purgeReactionTracking(key, endingTurn) {
49490
49490
  }
49491
49491
  }
49492
49492
  }
49493
+ function releaseTurnBufferGate(key) {
49494
+ if (!activeTurnStartedAt.has(key))
49495
+ return;
49496
+ activeTurnStartedAt.delete(key);
49497
+ shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted: true });
49498
+ if (activeTurnStartedAt.size === 0) {
49499
+ const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? "";
49500
+ if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
49501
+ const fr = redeliverBufferedInbound(pendingInboundBuffer, selfAgentForFlush, (m) => ipcServer.sendToAgent(selfAgentForFlush, m), inboundSpool);
49502
+ if (fr.redelivered > 0) {
49503
+ process.stderr.write(`telegram gateway: reply-released-gate flushed ${fr.redelivered}/${fr.drained} held inbound for ${selfAgentForFlush}${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ""}
49504
+ `);
49505
+ }
49506
+ }
49507
+ }
49508
+ }
49493
49509
  function endCurrentTurnAtomic(turn) {
49494
49510
  if (currentTurn !== turn)
49495
49511
  return;
@@ -51368,6 +51384,7 @@ ${url}`;
51368
51384
  turn.finalAnswerDelivered = true;
51369
51385
  finalizeStatusReaction(chat_id, threadId, "done");
51370
51386
  }
51387
+ releaseTurnBufferGate(statusKey(chat_id, threadId));
51371
51388
  }
51372
51389
  process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
51373
51390
  `);
@@ -51505,6 +51522,11 @@ async function executeStreamReply(args) {
51505
51522
  })) {
51506
51523
  turn.finalAnswerDelivered = true;
51507
51524
  }
51525
+ {
51526
+ const sChat = args.chat_id;
51527
+ const sThread = resolveThreadId(sChat, args.message_thread_id);
51528
+ releaseTurnBufferGate(statusKey(sChat, sThread));
51529
+ }
51508
51530
  return { content: [{ type: "text", text: `${result.status} (id: ${result.messageId ?? "pending"})` }] };
51509
51531
  }
51510
51532
  async function executeProgressUpdate(args) {
@@ -1412,6 +1412,78 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
1412
1412
  }
1413
1413
  }
1414
1414
 
1415
+ /**
1416
+ * Narrow buffer-gate release. Clears the per-key
1417
+ * `activeTurnStartedAt` entry and triggers the held-inbound flush
1418
+ * if the fleet went idle, WITHOUT touching the reaction
1419
+ * controller, the active-reaction message-id, or the typing loop.
1420
+ *
1421
+ * Why split from `purgeReactionTracking`. #1718's contract keeps
1422
+ * `activeStatusReactions[key]` alive across the turn so the
1423
+ * working-state ladder can re-paint on every tool/thinking event
1424
+ * (and the steer-vs-queue logic at the inbound handler reads the
1425
+ * controller — gateway.ts:8322-8323 — to classify mid-turn
1426
+ * messages). Wiping the controller mid-turn would either collapse
1427
+ * the ladder to 👍 prematurely (#1713 regression) or break the
1428
+ * steer detection.
1429
+ *
1430
+ * The BUFFER gate (`activeTurnStartedAt`) is a separate concern:
1431
+ * it gates `shouldBufferInbound` (gateway.ts:8603) and the
1432
+ * "claude is idle" flush at `purgeReactionTracking`'s tail. The
1433
+ * #1728/#1729 fix released both halves together by gating on
1434
+ * `isFinalAnswerReply`, but a trivial-prompt reply that sets
1435
+ * `disable_notification: true` and is < 200 chars (e.g. the model
1436
+ * mis-classifies "4" as an interim ack) returns false from
1437
+ * `isFinalAnswerReply`, so neither half releases and the gate
1438
+ * wedges (v0.13.30 UAT regression — every subsequent inbound logs
1439
+ * `held mid-turn ... will flush on turn-complete` forever).
1440
+ *
1441
+ * `releaseTurnBufferGate` is called from `executeReply` on EVERY
1442
+ * successful reply finalize — regardless of `isFinalAnswerReply` —
1443
+ * so the buffer gate releases independently of the reaction
1444
+ * state. The reaction controller stays for #1713's bidirectional
1445
+ * ladder + steer detection; only the gate flips.
1446
+ *
1447
+ * Idempotent: a second release is a no-op `.delete()` on an
1448
+ * already-empty key.
1449
+ *
1450
+ * @internal exported only via the `gateway.ts` module — used by
1451
+ * `executeReply`'s post-send block and by tests via source-level
1452
+ * pinning in `vault-approval-posture.test.ts` / wedge-guard suites.
1453
+ */
1454
+ function releaseTurnBufferGate(key: string): void {
1455
+ if (!activeTurnStartedAt.has(key)) return
1456
+ activeTurnStartedAt.delete(key)
1457
+ // Shadow trace so the structural turn-end metric still records.
1458
+ // outboundEmitted=true is correct here — we only reach this from
1459
+ // executeReply AFTER an outbound landed.
1460
+ shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted: true })
1461
+
1462
+ // Mirror the deterministic-delivery flush from
1463
+ // `purgeReactionTracking` (gateway.ts:1376-1399). When the fleet
1464
+ // hits zero-active-turns, drain any held inbound. This is the
1465
+ // load-bearing wedge fix: the gate that pinned msg 1874+ in
1466
+ // test-harness's 13:02 UAT now opens after the reply.
1467
+ if (activeTurnStartedAt.size === 0) {
1468
+ const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? ''
1469
+ if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
1470
+ const fr = redeliverBufferedInbound(
1471
+ pendingInboundBuffer,
1472
+ selfAgentForFlush,
1473
+ (m) => ipcServer.sendToAgent(selfAgentForFlush, m),
1474
+ inboundSpool,
1475
+ )
1476
+ if (fr.redelivered > 0) {
1477
+ process.stderr.write(
1478
+ `telegram gateway: reply-released-gate flushed ${fr.redelivered}/${fr.drained} ` +
1479
+ `held inbound for ${selfAgentForFlush}` +
1480
+ `${fr.rebuffered > 0 ? ` (${fr.rebuffered} re-buffered)` : ''}\n`,
1481
+ )
1482
+ }
1483
+ }
1484
+ }
1485
+ }
1486
+
1415
1487
  /**
1416
1488
  * Atomic null-and-purge for a wedged turn. Every site that ends a
1417
1489
  * turn by nulling `currentTurn` MUST also clear the turn's statusKey
@@ -4975,6 +5047,24 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4975
5047
  // observable side effects that #1718 deferred unconditionally.
4976
5048
  finalizeStatusReaction(chat_id, threadId, 'done')
4977
5049
  }
5050
+ // v0.13.30 follow-up — release the buffer gate on EVERY reply
5051
+ // finalize, not just on `isFinalAnswerReply`. The narrow
5052
+ // `finalizeStatusReaction` path above misses short replies that
5053
+ // set `disable_notification: true` (the model mis-classifies a
5054
+ // genuine answer as an interim ack — e.g. "4" for "what's
5055
+ // 2+2"). Pre-fix the gate stayed set forever and every later
5056
+ // inbound logged `held mid-turn ... will flush on turn-
5057
+ // complete` — but turn-complete never came because Claude
5058
+ // Code's `turn_duration` system event doesn't reliably land
5059
+ // for trivial-prompt turns. v0.13.30 UAT showed the regression
5060
+ // (msg 1873 reply at 13:02:46, msg 1874 held at 13:03:04, gate
5061
+ // never released).
5062
+ //
5063
+ // The reaction controller stays alive (preserves #1713
5064
+ // bidirectional ladder + the steer-vs-queue logic at
5065
+ // gateway.ts:8322 which reads `activeStatusReactions`). Only
5066
+ // the buffer gate flips.
5067
+ releaseTurnBufferGate(statusKey(chat_id, threadId))
4978
5068
  }
4979
5069
 
4980
5070
  process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(',')}] chunks=${chunks.length}\n`)
@@ -5231,6 +5321,17 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
5231
5321
  ) {
5232
5322
  turn.finalAnswerDelivered = true
5233
5323
  }
5324
+ // v0.13.30 follow-up — release the buffer gate on every successful
5325
+ // stream_reply too. Same rationale as executeReply: short replies
5326
+ // with `disable_notification: true` would otherwise wedge the gate
5327
+ // forever. `result.status` is always 'updated' | 'finalized'
5328
+ // (stream-reply-handler.ts:305) at this point — earlier failures
5329
+ // throw or return before reaching here.
5330
+ {
5331
+ const sChat = args.chat_id as string
5332
+ const sThread = resolveThreadId(sChat, args.message_thread_id as string | undefined)
5333
+ releaseTurnBufferGate(statusKey(sChat, sThread))
5334
+ }
5234
5335
  return { content: [{ type: 'text', text: `${result.status} (id: ${result.messageId ?? 'pending'})` }] }
5235
5336
  }
5236
5337
 
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Regression guard for the v0.13.30→v0.13.31 wedge fix: the buffer
3
+ * gate (`activeTurnStartedAt`) must release on EVERY successful
4
+ * reply / stream_reply finalize — not just on `isFinalAnswerReply`.
5
+ *
6
+ * Surfaced 2026-05-24 via the v0.13.30 UAT canary:
7
+ * 13:02:46 reply finalized for msg 1873 (charCount=67)
8
+ * 13:03:04 msg 1874 — held mid-turn (gate STILL open)
9
+ * 13:03:40 msg 1875 — held mid-turn (depth=2)
10
+ * 13:04:19 msg 1876 — held mid-turn (depth=3)
11
+ *
12
+ * Root cause: the trivial-prompt reply used `disable_notification:
13
+ * true` and was < 200 chars (the model classified "4" as an interim
14
+ * ack), so `isFinalAnswerReply` returned false, the
15
+ * `finalizeStatusReaction` gate in `executeReply` short-circuited,
16
+ * and the buffer gate stayed set. Pre-#1718 the gate released on
17
+ * every reply (via `endStatusReaction → purgeReactionTracking`);
18
+ * #1718 deferred everything to `turn_end`, then #1729 partially
19
+ * restored via `isFinalAnswerReply`-gated finalize. This fix
20
+ * decouples the buffer-gate release from the reaction-state
21
+ * finalize: every successful reply releases the gate, the reaction
22
+ * controller stays alive (preserves #1713 bidirectional ladder +
23
+ * the steer-vs-queue logic).
24
+ *
25
+ * The gateway IIFE is too entangled to instantiate in-process; we
26
+ * do source-level assertions like `reply-terminal-reaction.test.ts`
27
+ * does. If a future commit regresses the contract (re-narrows the
28
+ * gate release, or removes the helper), these assertions trip.
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest'
32
+ import { readFileSync } from 'node:fs'
33
+ import { resolve } from 'node:path'
34
+
35
+ const gatewaySrc = readFileSync(
36
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
37
+ 'utf-8',
38
+ )
39
+
40
+ describe('buffer-gate release decoupled from final-answer classification', () => {
41
+ // Extract the helper's docstring (everything between the matching
42
+ // `/**` block above `function releaseTurnBufferGate` and the
43
+ // function declaration). The body slice (used by other tests) is
44
+ // separate — see fnBody().
45
+ function fnDocstring(): string {
46
+ const beforeFn = gatewaySrc.split('function releaseTurnBufferGate')[0] ?? ''
47
+ const lastBlockOpen = beforeFn.lastIndexOf('/**')
48
+ if (lastBlockOpen < 0) return ''
49
+ return beforeFn.slice(lastBlockOpen)
50
+ }
51
+ function fnBody(): string {
52
+ // The function body — everything between `function
53
+ // releaseTurnBufferGate(...): void {` and its matching `}`. Use
54
+ // a simple brace-balance over the slice from open-brace onward.
55
+ const afterDecl = gatewaySrc.split('function releaseTurnBufferGate')[1] ?? ''
56
+ const openIdx = afterDecl.indexOf('{')
57
+ if (openIdx < 0) return ''
58
+ let depth = 0
59
+ for (let i = openIdx; i < afterDecl.length; i++) {
60
+ const ch = afterDecl[i]
61
+ if (ch === '{') depth++
62
+ else if (ch === '}') {
63
+ depth--
64
+ if (depth === 0) return afterDecl.slice(openIdx, i + 1)
65
+ }
66
+ }
67
+ return afterDecl.slice(openIdx)
68
+ }
69
+
70
+ it('declares a narrow `releaseTurnBufferGate` helper (not the full purgeReactionTracking)', () => {
71
+ expect(gatewaySrc).toMatch(/function releaseTurnBufferGate\(key: string\): void/)
72
+ // The helper docstring must explain WHY split from
73
+ // purgeReactionTracking — future readers need to know.
74
+ const doc = fnDocstring()
75
+ expect(doc).toMatch(/#1713/)
76
+ expect(doc).toMatch(/steer-vs-queue/)
77
+ })
78
+
79
+ it('releaseTurnBufferGate ONLY clears activeTurnStartedAt + flushes; does NOT touch activeStatusReactions', () => {
80
+ const body = fnBody()
81
+ expect(body).toMatch(/activeTurnStartedAt\.delete\(key\)/)
82
+ expect(body).toMatch(/pendingInboundBuffer/)
83
+ // Critical regression guard: the helper must NOT touch the
84
+ // reaction controller, else #1713's bidirectional ladder
85
+ // collapses to 👍 mid-turn.
86
+ expect(body).not.toMatch(/activeStatusReactions\.delete/)
87
+ expect(body).not.toMatch(/activeReactionMsgIds\.delete/)
88
+ // Also must NOT call finalizeStatusReaction or
89
+ // purgeReactionTracking (both would clear the controller).
90
+ expect(body).not.toMatch(/finalizeStatusReaction\(/)
91
+ expect(body).not.toMatch(/purgeReactionTracking\(/)
92
+ })
93
+
94
+ it('executeReply calls releaseTurnBufferGate OUTSIDE the isFinalAnswerReply branch', () => {
95
+ // Slice the executeReply post-send block (between the anchor
96
+ // comments and the next exported function).
97
+ const post = gatewaySrc.split("fresh sendMessage from reply tool is a user-visible")[1] ?? ''
98
+ const slice = post.split('\nasync function ')[0] ?? ''
99
+ // The narrow `isFinalAnswerReply`-gated finalize MUST stay (it
100
+ // emits the 👍 reaction on the final-answer happy path).
101
+ expect(slice).toMatch(/isFinalAnswerReply\(/)
102
+ expect(slice).toMatch(/finalizeStatusReaction\(/)
103
+ // The new unconditional buffer-gate release must ALSO be
104
+ // present and must be OUTSIDE the isFinalAnswerReply branch
105
+ // (so trivial-prompt non-notification replies still release
106
+ // the gate).
107
+ expect(slice).toMatch(/releaseTurnBufferGate\(statusKey\(chat_id, threadId\)\)/)
108
+ // Structural check: the release must appear AFTER the
109
+ // isFinalAnswerReply block's closing brace but BEFORE the
110
+ // post-send block ends. Easiest pin: it must NOT be inside the
111
+ // `if (turn != null && isFinalAnswerReply(...))` block.
112
+ const gateBlockOpen = slice.indexOf('if (turn != null && isFinalAnswerReply(')
113
+ const gateBlockClose = slice.indexOf('}', gateBlockOpen)
114
+ const releaseIdx = slice.indexOf('releaseTurnBufferGate(')
115
+ expect(gateBlockOpen).toBeGreaterThan(-1)
116
+ expect(gateBlockClose).toBeGreaterThan(gateBlockOpen)
117
+ expect(releaseIdx).toBeGreaterThan(gateBlockClose)
118
+ })
119
+
120
+ it('executeStreamReply calls releaseTurnBufferGate before its final return', () => {
121
+ const post =
122
+ gatewaySrc.split('async function executeStreamReply')[1]
123
+ ?.split('\nasync function ')[0] ?? ''
124
+ expect(post).toMatch(/releaseTurnBufferGate\(statusKey\(/)
125
+ })
126
+
127
+ it('the helper is invoked from executeReply / executeStreamReply only — not from new mid-turn paths', () => {
128
+ // Sanity: nothing else should call releaseTurnBufferGate. The
129
+ // helper is narrow on purpose. If future code adds new
130
+ // callsites that aren't reply-finalize, the steer-vs-queue
131
+ // semantics could drift.
132
+ const callMatches = gatewaySrc.match(/releaseTurnBufferGate\(/g) ?? []
133
+ // Definition + 2 callsites (executeReply, executeStreamReply) = 3.
134
+ // If this count grows the test catches it; reviewer must justify
135
+ // any new callsite.
136
+ expect(callMatches.length).toBe(3)
137
+ })
138
+ })