switchroom 0.13.1 → 0.13.2

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.
@@ -47248,8 +47248,8 @@ var {
47248
47248
  } = import__.default;
47249
47249
 
47250
47250
  // src/build-info.ts
47251
- var VERSION = "0.13.1";
47252
- var COMMIT_SHA = "b7489c5a";
47251
+ var VERSION = "0.13.2";
47252
+ var COMMIT_SHA = "afa0fbea";
47253
47253
 
47254
47254
  // src/cli/agent.ts
47255
47255
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
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": {
@@ -47679,10 +47679,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47679
47679
  }
47680
47680
 
47681
47681
  // ../src/build-info.ts
47682
- var VERSION = "0.13.1";
47683
- var COMMIT_SHA = "b7489c5a";
47684
- var COMMIT_DATE = "2026-05-21T00:27:54Z";
47685
- var LATEST_PR = 1609;
47682
+ var VERSION = "0.13.2";
47683
+ var COMMIT_SHA = "afa0fbea";
47684
+ var COMMIT_DATE = "2026-05-21T00:58:22Z";
47685
+ var LATEST_PR = 1610;
47686
47686
  var COMMITS_AHEAD_OF_TAG = 0;
47687
47687
 
47688
47688
  // gateway/boot-version.ts
@@ -48632,21 +48632,18 @@ function statusKey(chatId, threadId) {
48632
48632
  function streamKey3(chatId, threadId) {
48633
48633
  return chatKey(chatId, threadId);
48634
48634
  }
48635
- function clearReactionState(key) {
48635
+ function purgeReactionTracking(key, endingTurn) {
48636
+ const outboundEmitted = endingTurn != null ? endingTurn.replyCalled === true : currentTurn?.replyCalled === true;
48637
+ shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted });
48636
48638
  const msgInfo = activeReactionMsgIds.get(key);
48637
48639
  activeStatusReactions.delete(key);
48638
48640
  activeReactionMsgIds.delete(key);
48641
+ activeTurnStartedAt.delete(key);
48639
48642
  if (msgInfo) {
48640
48643
  const agentDir = resolveAgentDirFromEnv();
48641
48644
  if (agentDir != null)
48642
48645
  removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId);
48643
48646
  }
48644
- }
48645
- function purgeReactionTracking(key, endingTurn, outboundEmittedOverride) {
48646
- const outboundEmitted = outboundEmittedOverride !== undefined ? outboundEmittedOverride : endingTurn != null ? endingTurn.replyCalled === true : currentTurn?.replyCalled === true;
48647
- shadowEmit({ kind: "turnEnd", key, at: Date.now(), outboundEmitted });
48648
- clearReactionState(key);
48649
- activeTurnStartedAt.delete(key);
48650
48647
  if (activeTurnStartedAt.size === 0) {
48651
48648
  const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? "";
48652
48649
  if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
@@ -48796,7 +48793,7 @@ function endStatusReaction(chatId, threadId, outcome) {
48796
48793
  ctrl.setDone();
48797
48794
  else
48798
48795
  ctrl.setError();
48799
- clearReactionState(key);
48796
+ purgeReactionTracking(key);
48800
48797
  }
48801
48798
  function resolveThreadId(chat_id, explicit) {
48802
48799
  if (explicit != null)
@@ -49519,8 +49516,8 @@ startTimer({
49519
49516
  lastPtyPreviewByChat.delete(fbKey);
49520
49517
  preambleSuppressor.dropNow();
49521
49518
  endTurn(fbKey);
49522
- purgeReactionTracking(fbKey, undefined, false);
49523
- const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), (k) => purgeReactionTracking(k, undefined, false));
49519
+ purgeReactionTracking(fbKey);
49520
+ const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), purgeReactionTracking);
49524
49521
  if (turnMatchesFallback && currentTurn === wedgedTurn)
49525
49522
  currentTurn = null;
49526
49523
  try {
@@ -51342,6 +51339,7 @@ function handleSessionEvent(ev) {
51342
51339
  const ctrl = activeStatusReactions.get(ceKey);
51343
51340
  if (ctrl)
51344
51341
  ctrl.setError();
51342
+ purgeReactionTracking(ceKey);
51345
51343
  endTurn(ceKey);
51346
51344
  if (turn.answerStream != null) {
51347
51345
  turn.answerStream.stop();
@@ -51424,6 +51422,7 @@ function handleSessionEvent(ev) {
51424
51422
  unpinProgressCardForChat?.(chatId, threadId);
51425
51423
  if (ctrl)
51426
51424
  ctrl.setDone();
51425
+ purgeReactionTracking(statusKey(chatId, threadId));
51427
51426
  {
51428
51427
  const sKey = streamKey3(chatId, threadId);
51429
51428
  const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0;
@@ -51494,7 +51493,7 @@ function handleSessionEvent(ev) {
51494
51493
  if (recentCount > 0) {
51495
51494
  process.stderr.write(`telegram gateway: turn-flush suppressed \u2014 reply tool sent ${recentCount} message(s) within 2s
51496
51495
  `);
51497
- purgeReactionTracking(statusKey(backstopChatId, backstopThreadId), turn);
51496
+ purgeReactionTracking(statusKey(backstopChatId, backstopThreadId));
51498
51497
  return;
51499
51498
  }
51500
51499
  } catch {}
@@ -51579,13 +51578,14 @@ function handleSessionEvent(ev) {
51579
51578
  if (backstopCtrl)
51580
51579
  backstopCtrl.setError();
51581
51580
  } finally {
51582
- purgeReactionTracking(statusKey(backstopChatId, backstopThreadId), turn);
51581
+ purgeReactionTracking(statusKey(backstopChatId, backstopThreadId));
51583
51582
  }
51584
51583
  })();
51585
51584
  return;
51586
51585
  }
51587
51586
  if (ctrl)
51588
51587
  ctrl.setDone();
51588
+ purgeReactionTracking(statusKey(chatId, threadId));
51589
51589
  {
51590
51590
  const sKey = streamKey3(chatId, threadId);
51591
51591
  const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0;
@@ -1278,62 +1278,32 @@ function streamKey(chatId: string, threadId?: number | null): string {
1278
1278
  return chatKey(chatId, threadId)
1279
1279
  }
1280
1280
 
1281
- /**
1282
- * Reaction-state cleanup controller + msg-id maps + active-reaction
1283
- * file removal. PURE reaction-cleanup, no turn-end semantics:
1284
- * - does NOT emit shadow `turnEnd`
1285
- * - does NOT clear `activeTurnStartedAt` (turn-active marker)
1286
- * - does NOT fire the model-idle restart/flush gate
1287
- *
1288
- * Called from mid-turn signals like `endStatusReaction` (post-reply-tool,
1289
- * post-stream-reply-finalize) where the 👍 transition fires but the
1290
- * turn is still active. Per #1603 audit step 2: the reply tool was
1291
- * previously calling `purgeReactionTracking` here, which fired premature
1292
- * shadow `turnEnd` events and cleared `activeTurnStartedAt` mid-turn —
1293
- * the latter would trigger the model-idle restart probe and
1294
- * pendingInbound flush as if claude had gone idle.
1295
- */
1296
- function clearReactionState(key: string): void {
1281
+ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
1282
+ // Phase 2b: turn end. The key was registered via setTurnStarted when
1283
+ // the inbound arrived; purge is the canonical turn-end signal.
1284
+ //
1285
+ // outboundEmitted: read from the explicit `endingTurn` parameter when
1286
+ // provided (canonical path via endCurrentTurnAtomic — module-scope
1287
+ // currentTurn is already null by the time we get here), falling back
1288
+ // to `currentTurn?.replyCalled` for the legacy callsites that haven't
1289
+ // been threaded yet (sibling-key purges, restart-init cleanup).
1290
+ // Without this explicit-turn handoff the shadow trace would report
1291
+ // outboundEmitted=false on every replied turn (the dominant happy
1292
+ // path), producing strictly worse data than the blind `true` it
1293
+ // replaced. Invariant #5's `lastOutboundAt` correctness depends on
1294
+ // this signal being accurate.
1295
+ const outboundEmitted = endingTurn != null
1296
+ ? endingTurn.replyCalled === true
1297
+ : currentTurn?.replyCalled === true
1298
+ shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted })
1297
1299
  const msgInfo = activeReactionMsgIds.get(key)
1298
1300
  activeStatusReactions.delete(key)
1299
1301
  activeReactionMsgIds.delete(key)
1302
+ activeTurnStartedAt.delete(key)
1300
1303
  if (msgInfo) {
1301
1304
  const agentDir = resolveAgentDirFromEnv()
1302
1305
  if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
1303
1306
  }
1304
- }
1305
-
1306
- function purgeReactionTracking(
1307
- key: string,
1308
- endingTurn?: CurrentTurn,
1309
- outboundEmittedOverride?: boolean,
1310
- ): void {
1311
- // Phase 2b: turn end. The key was registered via setTurnStarted when
1312
- // the inbound arrived; purge is the canonical turn-end signal.
1313
- //
1314
- // outboundEmitted derivation, in precedence order:
1315
- // 1. Explicit `outboundEmittedOverride` (e.g. silence-poke
1316
- // framework fallback FORCES false because the 5-min fallback
1317
- // firing proves visible delivery never happened — regardless of
1318
- // whatever `replyCalled` the wedged turn object carries).
1319
- // 2. `endingTurn.replyCalled` when the canonical caller threads
1320
- // the authoritative turn (endCurrentTurnAtomic path; module-scope
1321
- // currentTurn is already null by the time we get here).
1322
- // 3. `currentTurn?.replyCalled` fallback for the (now-vanishing)
1323
- // legacy callsites. Without the explicit-turn handoff the shadow
1324
- // trace would report outboundEmitted=false on every replied
1325
- // turn (the dominant happy path), producing strictly worse data
1326
- // than the blind `true` it replaced. Invariant #5's
1327
- // `lastOutboundAt` correctness depends on this signal being
1328
- // accurate.
1329
- const outboundEmitted = outboundEmittedOverride !== undefined
1330
- ? outboundEmittedOverride
1331
- : endingTurn != null
1332
- ? endingTurn.replyCalled === true
1333
- : currentTurn?.replyCalled === true
1334
- shadowEmit({ kind: 'turnEnd', key: key as _ChatKey, at: Date.now(), outboundEmitted })
1335
- clearReactionState(key)
1336
- activeTurnStartedAt.delete(key)
1337
1307
 
1338
1308
  // If no more active turns and a restart is pending, perform it now.
1339
1309
  //
@@ -1623,24 +1593,12 @@ async function resolveCompactCard(
1623
1593
  }
1624
1594
 
1625
1595
  function endStatusReaction(chatId: string, threadId: number | undefined, outcome: 'done' | 'error'): void {
1626
- // Mid-turn signal: the reply tool fired, or stream_reply finalized,
1627
- // and the status-reaction needs to transition to its terminal emoji
1628
- // (👍 / ⚠️). The turn itself is still active — the canonical turn-end
1629
- // signal is `endCurrentTurnAtomic(turn)`, which runs later via the
1630
- // turn_end handler / context-exhaust path / silent-marker path.
1631
- //
1632
- // Pre-#1603 audit step 2 (this commit), this called
1633
- // `purgeReactionTracking(key)` directly, which would fire shadow
1634
- // `turnEnd` and clear the turn-active marker mid-turn — the latter
1635
- // triggering the model-idle restart probe + pendingInbound flush as
1636
- // if claude had gone idle. Use `clearReactionState` to only do the
1637
- // reaction-cleanup work.
1638
1596
  const key = statusKey(chatId, threadId)
1639
1597
  const ctrl = activeStatusReactions.get(key)
1640
1598
  if (!ctrl) return
1641
1599
  if (outcome === 'done') ctrl.setDone()
1642
1600
  else ctrl.setError()
1643
- clearReactionState(key)
1601
+ purgeReactionTracking(key)
1644
1602
  }
1645
1603
 
1646
1604
  function resolveThreadId(chat_id: string, explicit?: string | number | null): number | undefined {
@@ -3135,15 +3093,7 @@ silencePoke.startTimer({
3135
3093
  // Drop silence-poke state and clear turn-active so the next inbound
3136
3094
  // for this chat starts a fresh turn instead of queueing forever.
3137
3095
  silencePoke.endTurn(fbKey)
3138
- // PR 3b step 5 (#1603 audit): force outboundEmitted=false. The
3139
- // framework fallback fires precisely because visible delivery
3140
- // didn't happen in 5 min — `wedgedTurn.replyCalled` may have been
3141
- // set during the turn (e.g. reply tool invoked but Telegram side
3142
- // never confirmed delivery), but from the user's perspective no
3143
- // outbound landed. The state machine's `noteOutbound` effect
3144
- // must NOT fire for this path. Pass `undefined` for endingTurn
3145
- // and `false` as the explicit override.
3146
- purgeReactionTracking(fbKey, undefined, false)
3096
+ purgeReactionTracking(fbKey)
3147
3097
  // Defense-in-depth: the fallback's purgeReactionTracking above
3148
3098
  // clears the canonical statusKey(chatId, threadId) for fbKey
3149
3099
  // only. activeTurnStartedAt can hold sibling entries for the
@@ -3156,14 +3106,10 @@ silencePoke.startTimer({
3156
3106
  // purger. Multi-chat-safe — only touches keys for fbChatId, so
3157
3107
  // #1546's intentional cross-chat safety guard is preserved.
3158
3108
  // See turn-state-purge.ts.
3159
- //
3160
- // Same `outboundEmitted=false` rationale as the bare call above —
3161
- // wrap the purger so every sibling-key purge emits a fallback
3162
- // shadow turnEnd with the truthful "no visible delivery" signal.
3163
3109
  const fbExtraPurge = purgeStaleTurnsForChat(
3164
3110
  fbChatId,
3165
3111
  activeTurnStartedAt.keys(),
3166
- (k) => purgeReactionTracking(k, undefined, false),
3112
+ purgeReactionTracking,
3167
3113
  )
3168
3114
  // Null `currentTurn` if it's still pointing at the wedged turn —
3169
3115
  // when claude eventually fires a late `turn_end` for this session
@@ -5882,10 +5828,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5882
5828
  const ceKey = statusKey(chatId, threadId)
5883
5829
  const ctrl = activeStatusReactions.get(ceKey)
5884
5830
  if (ctrl) ctrl.setError()
5885
- // Duplicate-emit removed (#1603 audit, step 1): the canonical
5886
- // endCurrentTurnAtomic(turn) call at line ~5851 below already
5887
- // invokes purgeReactionTracking on the same ceKey. The bare
5888
- // call here was firing a second shadow `turnEnd` per traversal.
5831
+ purgeReactionTracking(ceKey)
5889
5832
  // Surfaced during CC-5 investigation (`docs/status-ask-cause-classes.md`):
5890
5833
  // the context-exhaust bail path teardown was missing
5891
5834
  // `silencePoke.endTurn(key)`. Without it, the silence-poke state for
@@ -6043,10 +5986,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6043
5986
  // Fall through to normal state cleanup (ctrl.setDone, purge, etc.)
6044
5987
  // but skip the regular closeProgressLane so we don't re-finalize.
6045
5988
  if (ctrl) ctrl.setDone()
6046
- // Duplicate-emit removed (#1603 audit, step 1): endCurrentTurnAtomic(turn)
6047
- // at line ~6049 below invokes purgeReactionTracking on the same key
6048
- // (statusKey(chatId, threadId)). The bare call here was firing a
6049
- // second shadow `turnEnd` per silent-marker traversal.
5989
+ purgeReactionTracking(statusKey(chatId, threadId))
6050
5990
  // Match the normal turn_end path's telemetry so silent-marker turns
6051
5991
  // still appear in turn-duration graphs.
6052
5992
  {
@@ -6187,15 +6127,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6187
6127
  // mirroring this contract — so reply-only turns transition
6188
6128
  // to terminal 👍 in their own success path rather than
6189
6129
  // relying on this dedup heuristic.
6190
- //
6191
- // PR 3b step 3 (#1603 audit): thread the captured `turn`
6192
- // explicitly. `endCurrentTurnAtomic(turn)` ran at line ~6120
6193
- // before this IIFE started, so `currentTurn === null` by
6194
- // now — without an explicit endingTurn argument, the shadow
6195
- // trace would read `outboundEmitted=false` for this dedup
6196
- // path even though `recentCount > 0` proves the reply tool
6197
- // did fire (turn.replyCalled === true).
6198
- purgeReactionTracking(statusKey(backstopChatId, backstopThreadId), turn)
6130
+ purgeReactionTracking(statusKey(backstopChatId, backstopThreadId))
6199
6131
  return
6200
6132
  }
6201
6133
  } catch {}
@@ -6323,35 +6255,14 @@ function handleSessionEvent(ev: SessionEvent): void {
6323
6255
  process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
6324
6256
  if (backstopCtrl) backstopCtrl.setError()
6325
6257
  } finally {
6326
- // PR 3b step 3 (#1603 audit): thread the captured `turn`
6327
- // explicitly. The turn-flush backstop runs inside this IIFE
6328
- // after `endCurrentTurnAtomic(turn)` already nulled
6329
- // `currentTurn` at line ~6120. Without threading, the shadow
6330
- // trace would read `outboundEmitted=currentTurn?.replyCalled
6331
- // === undefined` → false. For the turn-flush path
6332
- // `turn.replyCalled` is `false` regardless (the model didn't
6333
- // call the reply tool — the gateway backstop did the work),
6334
- // so the threaded value matches the existing fallback here.
6335
- // But pinning the source via the captured turn matches the
6336
- // canonical pattern and survives any future change to how
6337
- // `currentTurn` is sequenced.
6338
- purgeReactionTracking(statusKey(backstopChatId, backstopThreadId), turn)
6258
+ purgeReactionTracking(statusKey(backstopChatId, backstopThreadId))
6339
6259
  }
6340
6260
  })()
6341
6261
  return
6342
6262
  }
6343
6263
 
6344
6264
  if (ctrl) ctrl.setDone()
6345
- // Duplicate-emit removed (#1603 audit, step 4 — the audit's
6346
- // original "route through endCurrentTurnAtomic" recommendation
6347
- // missed that this same code path already calls
6348
- // `endCurrentTurnAtomic(turn)` ~90 lines below at line ~6412
6349
- // on the same key — `chatId === turn.sessionChatId` and
6350
- // `threadId === turn.sessionThreadId` per the bindings at
6351
- // ~5946-5947. Removing this bare call closes the last duplicate
6352
- // shadow-`turnEnd` emit on the dominant happy-path turn-end
6353
- // tail; the canonical primitive below still fires the single
6354
- // authoritative turnEnd with the threaded turn).
6265
+ purgeReactionTracking(statusKey(chatId, threadId))
6355
6266
  {
6356
6267
  const sKey = streamKey(chatId, threadId)
6357
6268
  const turnDurationMs = turn.startedAt > 0 ? Date.now() - turn.startedAt : 0