switchroom 0.13.27 → 0.13.28

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.27";
47440
- var COMMIT_SHA = "a158e029";
47439
+ var VERSION = "0.13.28";
47440
+ var COMMIT_SHA = "eceed7db";
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.27",
3
+ "version": "0.13.28",
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": {
@@ -48464,10 +48464,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
48464
48464
  }
48465
48465
 
48466
48466
  // ../src/build-info.ts
48467
- var VERSION = "0.13.27";
48468
- var COMMIT_SHA = "a158e029";
48469
- var COMMIT_DATE = "2026-05-24T09:47:16Z";
48470
- var LATEST_PR = 1727;
48467
+ var VERSION = "0.13.28";
48468
+ var COMMIT_SHA = "eceed7db";
48469
+ var COMMIT_DATE = "2026-05-24T11:11:54Z";
48470
+ var LATEST_PR = 1730;
48471
48471
  var COMMITS_AHEAD_OF_TAG = 0;
48472
48472
 
48473
48473
  // gateway/boot-version.ts
@@ -51326,6 +51326,7 @@ ${url}`;
51326
51326
  noteSignal(statusKey(chat_id, threadId), Date.now());
51327
51327
  if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
51328
51328
  turn.finalAnswerDelivered = true;
51329
+ finalizeStatusReaction(chat_id, threadId, "done");
51329
51330
  }
51330
51331
  }
51331
51332
  process.stderr.write(`telegram channel: reply: finalized chatId=${chat_id} messageIds=[${sentIds.join(",")}] chunks=${chunks.length}
@@ -4931,26 +4931,49 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4931
4931
  } catch { /* best-effort signal */ }
4932
4932
  // #203: fresh sendMessage from reply tool is a user-visible signal.
4933
4933
  signalTracker.noteSignal(statusKey(chat_id, threadId), Date.now())
4934
- // #1713: the reply tool is a NON-EVENT for the status reaction.
4935
- // The reaction reflects current turn activity, not delivery state —
4936
- // only the `turn_end` IPC handler finalizes (👍). A plain `reply`
4937
- // mid-turn or as the final answer does not change the emoji on the
4938
- // user's inbound message; the next turn_end does. This is a
4939
- // deliberate revert of the PR #602 follow-up that wired delivery-
4940
- // confirmation into the terminal path (see #1713 issue body for
4941
- // the rationale: "delivery confirmation turn end").
4942
- // #1664 mark the turn's final answer as delivered when this reply
4943
- // looks like the real answer rather than an interim ack. The
4944
- // classification (notification-bearing OR substantive length) lives
4945
- // in `isFinalAnswerReply`. Without this, a turn that ack'd then ended
4946
- // with the real answer as plain transcript text (#1664) would look
4947
- // "delivered" because replyCalled is true — and the silent-end
4948
- // re-prompt would never engage. `rawText` is the model's own answer
4949
- // text, measured before HTML conversion / Telegraph-link
4950
- // substitution. Writes `turn` (pinned at executeReply entry) so the
4951
- // flag always lands on the turn this reply belongs to.
4934
+ // #1713: the reply tool is a NON-EVENT for the status reaction
4935
+ // WHEN IT'S AN INTERIM ACK. The reaction reflects current turn
4936
+ // activity, not delivery state interim acks must not collapse
4937
+ // the working-state ladder to 👍.
4938
+ //
4939
+ // #1728 carve-out (2026-05-24): when this reply IS the final
4940
+ // answer (`isFinalAnswerReply` returns true same classifier
4941
+ // #1664 uses for silent-end re-prompt gating), it IS effectively
4942
+ // turn-end and we MUST finalize here. Rationale: Claude Code's
4943
+ // `turn_duration` system event is unreliable for the trivial-
4944
+ // prompt happy path (driver sends "what's 2+2", model replies
4945
+ // "4", no `turn_duration` ever lands in the JSONL session tail).
4946
+ // Pre-#1718 this wedge was masked by the legacy
4947
+ // `endStatusReaction` shim running unconditionally on every
4948
+ // reply (outcome='done'); #1718 removed that call site
4949
+ // intending `turn_end` to be the sole terminal trigger. The
4950
+ // contract was right in spirit but `turn_end` doesn't fire 100%
4951
+ // of the time, so the buffer gate (activeTurnStartedAt) stays
4952
+ // set forever and every subsequent inbound gets `held mid-turn`
4953
+ // and never delivered. v0.13.27 shipped + reverted on this
4954
+ // failure mode (#1728).
4955
+ //
4956
+ // Net contract:
4957
+ // - interim ack reply (isFinalAnswerReply === false)
4958
+ // → non-event, no reaction finalize, buffer gate stays
4959
+ // - final-answer reply (isFinalAnswerReply === true)
4960
+ // → finalize reaction (debounced 👍) + release buffer
4961
+ // gate via purgeReactionTracking (called inside
4962
+ // finalizeStatusReaction). currentTurn stays alive so
4963
+ // a subsequent `turn_end` still cleans up its share
4964
+ // idempotently.
4965
+ //
4966
+ // #1664 — `turn.finalAnswerDelivered = true` keeps the silent-
4967
+ // end re-prompt from spuriously firing on a delivered final.
4952
4968
  if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
4953
4969
  turn.finalAnswerDelivered = true
4970
+ // #1728: release the buffer gate + emit terminal 👍. Mid-turn
4971
+ // acks bypass this branch and remain non-events for the
4972
+ // reaction (preserves #1713). The full turn-state teardown
4973
+ // (nulling `currentTurn`, the per-turn cleanup) still runs in
4974
+ // the `turn_end` handler when it lands; this only fires the
4975
+ // observable side effects that #1718 deferred unconditionally.
4976
+ finalizeStatusReaction(chat_id, threadId, 'done')
4954
4977
  }
4955
4978
  }
4956
4979
 
@@ -1,39 +1,44 @@
1
1
  /**
2
- * #1713 — plain `reply` tool is a NON-EVENT for the status reaction.
2
+ * #1713 + #1728 interim-ack `reply` tool is a non-event for the
3
+ * status reaction; final-answer `reply` finalizes.
3
4
  *
4
- * History. PR #602 follow-up wired `executeReply` to fire the terminal
5
- * 👍 after at least one chunk landed, mirroring the (now-also-removed)
6
- * stream_reply Bug Z behaviour. #1713 reverts both: the status reaction
7
- * reflects current turn activity, not delivery state. Only the
8
- * gateway's `turn_end` IPC handler finalizes the reaction. Mid-turn
9
- * replies ack or final must not change the emoji.
5
+ * History.
6
+ * - PR #602 follow-up wired `executeReply` to fire the terminal 👍
7
+ * after at least one chunk landed.
8
+ * - #1713 (#1718) reverted that: reaction reflects current turn
9
+ * activity, not delivery state. Made `turn_end` the sole terminal
10
+ * trigger so mid-turn ACK replies don't collapse the working-state
11
+ * ladder to 👍.
12
+ * - #1728 (this fix) carve-out: Claude Code's `turn_duration` system
13
+ * event is unreliable for the trivial-prompt happy path, leaving
14
+ * `activeTurnStartedAt` set forever and every subsequent inbound
15
+ * stuck "held mid-turn" (the v0.13.27 wedge). When the reply IS
16
+ * the final answer (`isFinalAnswerReply` returns true), executeReply
17
+ * calls `finalizeStatusReaction` to release the buffer gate and
18
+ * emit the (debounced 3500ms) terminal 👍. Interim acks bypass this
19
+ * branch and remain non-events for the reaction.
10
20
  *
11
- * This file pins the new invariant: there is no `endStatusReaction`
12
- * call inside the executeReply post-send block. The post-send block
13
- * now records signal-tracker / outbound-dedup / final-answer state
14
- * only reaction state is owned by turn_end.
21
+ * Net contract pinned here:
22
+ * - executeReply post-send block must NOT call the legacy
23
+ * `endStatusReaction('done')` (the pre-#1713 bug class).
24
+ * - executeReply MUST call `finalizeStatusReaction` gated on
25
+ * `isFinalAnswerReply` so the buffer gate releases on final answer.
15
26
  *
16
27
  * The gateway IIFE / executeReply body are too entangled to import
17
- * directly, so we model the post-#1713 contract here. If executeReply
18
- * regresses (re-adds a terminal-reaction call), the inline review
19
- * comment guarding `if (sentIds.length > 0)` and this test should both
20
- * catch it.
28
+ * directly, so we do source-level assertions.
21
29
  */
22
30
  import { describe, it, expect, vi } from 'vitest'
23
31
 
24
- describe('#1713 — plain reply tool is a non-event for the reaction', () => {
25
- it('executeReply post-send block does NOT call endStatusReaction', () => {
26
- // Read the source to assert the contract — there should be no
27
- // `endStatusReaction(... 'done')` call inside the post-send
28
- // `if (sentIds.length > 0)` block in executeReply.
32
+ describe('#1713 + #1728 — reply tool reaction contract', () => {
33
+ it('executeReply post-send block does NOT call legacy endStatusReaction', () => {
34
+ // The pre-#1713 bug class was `endStatusReaction(chat_id, threadId,
35
+ // 'done')` firing 👍 unconditionally on every reply (including
36
+ // mid-turn acks). That call must stay removed.
29
37
  //
30
38
  // We do a coarse-grained source-level check rather than a unit
31
39
  // test of a copied helper. If/when the executeReply body is
32
40
  // extracted into its own function this can become a proper unit
33
41
  // test; until then the source-level guard is the safest pin.
34
- //
35
- // The intent: a future commit that re-adds the call (regressing
36
- // #1713) will trip this assertion.
37
42
  // eslint-disable-next-line @typescript-eslint/no-require-imports
38
43
  const fs = require('node:fs') as typeof import('node:fs')
39
44
  // eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -53,6 +58,44 @@ describe('#1713 — plain reply tool is a non-event for the reaction', () => {
53
58
  expect(slice).not.toMatch(/endStatusReaction\([^)]*'done'\)/)
54
59
  })
55
60
 
61
+ it('executeReply post-send block DOES call finalizeStatusReaction gated on isFinalAnswerReply (#1728 wedge fix)', () => {
62
+ // #1728 — the v0.13.27 wedge: `turn_duration` system events from
63
+ // Claude Code don't reliably land for trivial-prompt turns, so
64
+ // activeTurnStartedAt never clears and every subsequent inbound
65
+ // gets held mid-turn. The fix: when executeReply detects a final-
66
+ // answer reply (the same `isFinalAnswerReply` classifier #1664
67
+ // uses for silent-end re-prompt gating), trigger
68
+ // `finalizeStatusReaction` to release the buffer gate. Interim
69
+ // acks (isFinalAnswerReply === false) MUST bypass this branch and
70
+ // remain non-events for the reaction (preserves #1713).
71
+ //
72
+ // If a future commit removes the finalizeStatusReaction call or
73
+ // un-gates it from isFinalAnswerReply, the wedge returns.
74
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
75
+ const fs = require('node:fs') as typeof import('node:fs')
76
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
77
+ const path = require('node:path') as typeof import('node:path')
78
+ const src = fs.readFileSync(
79
+ path.resolve(__dirname, '../gateway/gateway.ts'),
80
+ 'utf8',
81
+ )
82
+ const anchor = src.indexOf("fresh sendMessage from reply tool is a user-visible")
83
+ expect(anchor).toBeGreaterThan(-1)
84
+ const slice = src.slice(anchor, anchor + 3000)
85
+ // The finalize MUST appear in the post-send block.
86
+ expect(slice).toMatch(/finalizeStatusReaction\(/)
87
+ // It MUST be gated by isFinalAnswerReply (the classifier prevents
88
+ // interim acks from firing 👍, which would regress #1713).
89
+ expect(slice).toMatch(/isFinalAnswerReply\(/)
90
+ // Sanity: the finalize MUST appear AFTER the isFinalAnswerReply
91
+ // check (i.e. inside the gated branch), not as a sibling that
92
+ // fires unconditionally.
93
+ const gateIdx = slice.indexOf('isFinalAnswerReply(')
94
+ const finalizeIdx = slice.indexOf('finalizeStatusReaction(')
95
+ expect(gateIdx).toBeGreaterThan(-1)
96
+ expect(finalizeIdx).toBeGreaterThan(gateIdx)
97
+ })
98
+
56
99
  it('reply tool deps no longer wire a status-reaction terminal callback', () => {
57
100
  // Post-#1713 the stream-reply-handler has no call site for
58
101
  // `deps.endStatusReaction`. Post follow-up cleanup, the dep itself