switchroom 0.12.19 → 0.12.20

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.
@@ -14026,7 +14026,11 @@ var VaultConfigSchema = exports_external.object({
14026
14026
  path: ["approvalAuth"]
14027
14027
  });
14028
14028
  }
14029
- }).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
14029
+ }).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
14030
+ backup: exports_external.object({
14031
+ destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
14032
+ retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
14033
+ }).optional().describe("Configuration for `switchroom vault backup`. Optional — the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located — `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
14030
14034
  });
14031
14035
  var QuotaConfigSchema = exports_external.object({
14032
14036
  weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
@@ -11285,7 +11285,11 @@ var init_schema = __esm(() => {
11285
11285
  path: ["approvalAuth"]
11286
11286
  });
11287
11287
  }
11288
- }).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
11288
+ }).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
11289
+ backup: exports_external.object({
11290
+ destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
11291
+ retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
11292
+ }).optional().describe("Configuration for `switchroom vault backup`. Optional — the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located — `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
11289
11293
  });
11290
11294
  QuotaConfigSchema = exports_external.object({
11291
11295
  weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
@@ -11285,7 +11285,11 @@ var init_schema = __esm(() => {
11285
11285
  path: ["approvalAuth"]
11286
11286
  });
11287
11287
  }
11288
- }).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
11288
+ }).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
11289
+ backup: exports_external.object({
11290
+ destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
11291
+ retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
11292
+ }).optional().describe("Configuration for `switchroom vault backup`. Optional — the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located — `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
11289
11293
  });
11290
11294
  QuotaConfigSchema = exports_external.object({
11291
11295
  weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
@@ -16031,7 +16035,7 @@ class VaultBroker {
16031
16035
  chownSync(abs, operatorUid, operatorUid);
16032
16036
  } catch {}
16033
16037
  const unlockServer = net.createServer((sock) => {
16034
- this._handleUnlockConnection(sock);
16038
+ this._handleUnlockConnection(sock, true);
16035
16039
  });
16036
16040
  unlockServer.on("error", (err) => rejectP(err));
16037
16041
  unlockServer.listen(unlockAbs, () => {
@@ -17031,9 +17035,9 @@ class VaultBroker {
17031
17035
  socket.write(encodeResponse(errorResponse("INTERNAL", msg)));
17032
17036
  }
17033
17037
  }
17034
- _handleUnlockConnection(socket) {
17038
+ _handleUnlockConnection(socket, isOperator = false) {
17035
17039
  let unlockPeer = null;
17036
- if (process.platform === "linux") {
17040
+ if (!isOperator && process.platform === "linux") {
17037
17041
  unlockPeer = this.testOpts._testIdentify ? this.testOpts._testIdentify(this.unlockSocketPath, socket) : identify(this.unlockSocketPath, socket);
17038
17042
  if (unlockPeer === null) {
17039
17043
  this.auditLogger.write({
@@ -17048,9 +17052,9 @@ class VaultBroker {
17048
17052
  return;
17049
17053
  }
17050
17054
  }
17051
- const auditPid = unlockPeer?.pid ?? process.pid;
17052
- const auditCaller = unlockPeer !== null ? callerFromPeer(unlockPeer) : `pid:${process.pid}`;
17053
- const auditCgroup = unlockPeer?.systemdUnit ?? undefined;
17055
+ const auditPid = isOperator ? process.pid : unlockPeer?.pid ?? process.pid;
17056
+ const auditCaller = isOperator ? "operator" : unlockPeer !== null ? callerFromPeer(unlockPeer) : `pid:${process.pid}`;
17057
+ const auditCgroup = isOperator ? undefined : unlockPeer?.systemdUnit ?? undefined;
17054
17058
  let buffer = "";
17055
17059
  socket.on("data", (chunk) => {
17056
17060
  buffer += chunk.toString("utf8");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.19",
3
+ "version": "0.12.20",
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": {
@@ -26,7 +26,7 @@
26
26
  "test:vitest": "vitest run",
27
27
  "test:bun": "bun test src/watchdog/state.test.ts src/watchdog/policy.test.ts src/vault/grants.test.ts src/vault/write-grants.test.ts src/vault/broker/server-grants.test.ts src/vault/broker/server-write-grants.test.ts src/vault/broker/server-mint-grant-passphrase-attest.test.ts src/vault/broker/server-passphrase-attest.test.ts src/vault/broker/server-mint-grant-posture-attest.test.ts src/vault/broker/client-token.test.ts src/vault/broker/server-unlock.test.ts src/vault/broker/auto-unlock.test.ts src/vault/broker/drift-detection.test.ts tests/vault-broker-passphrase.test.ts src/cli/vault-get-broker.test.ts src/vault/resolver-via-broker.test.ts src/vault/broker/scope.test.ts src/vault/broker/server.test.ts src/drive/disconnect.test.ts src/drive/grants.test.ts src/drive/oauth.test.ts src/drive/onboarding.test.ts src/drive/reconciler.test.ts src/drive/vault-slots.test.ts src/drive/wrapper.test.ts src/vault/approvals/kernel.test.ts src/vault/approvals/schema-idempotent.test.ts src/vault/broker/server-approvals.test.ts telegram-plugin/tests/boot-probes.test.ts telegram-plugin/tests/boot-version-string.test.ts telegram-plugin/tests/history.test.ts telegram-plugin/tests/history-reaper.test.ts telegram-plugin/tests/ipc-server-client.test.ts telegram-plugin/tests/ipc-server-race.test.ts telegram-plugin/tests/gateway-bridge.test.ts telegram-plugin/tests/gateway-startup-mutex.test.ts telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts telegram-plugin/tests/boot-card-dedupe.test.ts telegram-plugin/tests/boot-card-reason.test.ts telegram-plugin/tests/progress-update.test.ts telegram-plugin/tests/quota-cache.test.ts telegram-plugin/tests/silent-reply-guard.test.ts telegram-plugin/tests/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/uat/load-env.test.ts",
28
28
  "test:watch": "vitest",
29
- "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs",
29
+ "lint": "tsc --noEmit && node scripts/check-plugin-references.mjs && bash scripts/check-bot-api-wrapping.sh && node scripts/check-bun-test-imports.mjs && node scripts/check-no-pii-secrets.mjs && node scripts/check-vault-test-hermeticity.mjs",
30
30
  "lint:tsc": "tsc --noEmit",
31
31
  "lint:plugin-references": "node scripts/check-plugin-references.mjs",
32
32
  "lint:bot-api-wrapping": "bash scripts/check-bot-api-wrapping.sh",
@@ -23929,7 +23929,11 @@ var init_schema = __esm(() => {
23929
23929
  path: ["approvalAuth"]
23930
23930
  });
23931
23931
  }
23932
- }).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation.")
23932
+ }).describe("Vault-broker daemon configuration. The broker holds the decrypted vault " + "in memory and serves secrets to cron scripts via a Unix socket, so the " + "vault passphrase is entered once at startup rather than per-cron invocation."),
23933
+ backup: exports_external.object({
23934
+ destination: exports_external.string().optional().describe("Destination directory for `switchroom vault backup`. " + "When unset, the CLI defaults to " + "`~/.switchroom-config/vault-backups/` if that operator config " + "repo exists, else `~/.switchroom/vault-backups/`. " + "Path is tilde-expanded at read time. " + "MUST NOT be `~/.switchroom/vault/` (the broker bind-mount dir, " + "validated by `switchroom apply` against an artifact allowlist)."),
23935
+ retain: exports_external.number().int().nonnegative().default(30).describe("How many of the most-recent backups to keep in the destination dir. " + "Older ones are pruned after each new backup is written. Default 30 " + "= roughly a month at daily cadence.")
23936
+ }).optional().describe("Configuration for `switchroom vault backup`. Optional \u2014 the CLI works " + "with built-in defaults if this block is absent. The backed-up file is " + "the AES-256-GCM-encrypted vault envelope; the operator passphrase " + "remains the gate, so committing backups to a private git repo extends " + "durability without weakening encryption (provided the auto-unlock " + "blob is NEVER co-located \u2014 `vault backup` refuses to write into a " + "directory that contains an auto-unlock-shaped sibling).")
23933
23937
  });
23934
23938
  QuotaConfigSchema = exports_external.object({
23935
23939
  weekly_budget_usd: exports_external.number().positive().optional().describe("Weekly USD spend budget. If unset, the greeting shows raw usage only."),
@@ -43725,6 +43729,27 @@ function createInboundSpool(opts) {
43725
43729
  };
43726
43730
  }
43727
43731
 
43732
+ // gateway/turn-state-purge.ts
43733
+ function purgeStaleTurnsForChat(chatId, keys, purger) {
43734
+ if (!chatId)
43735
+ return { purged: [] };
43736
+ const purged = [];
43737
+ const snapshot = [];
43738
+ for (const k of keys)
43739
+ snapshot.push(k);
43740
+ for (const key of snapshot) {
43741
+ const sep3 = key.indexOf(":");
43742
+ if (sep3 < 0)
43743
+ continue;
43744
+ const keyChat = key.slice(0, sep3);
43745
+ if (keyChat !== chatId)
43746
+ continue;
43747
+ purger(key);
43748
+ purged.push(key);
43749
+ }
43750
+ return { purged };
43751
+ }
43752
+
43728
43753
  // gateway/inbound-delivery-gate.ts
43729
43754
  function decideInboundDelivery(input) {
43730
43755
  if (input.turnInFlight && !input.isSteering)
@@ -47090,11 +47115,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47090
47115
  }
47091
47116
 
47092
47117
  // ../src/build-info.ts
47093
- var VERSION = "0.12.19";
47094
- var COMMIT_SHA = "5f6810ed";
47095
- var COMMIT_DATE = "2026-05-19T13:30:31Z";
47096
- var LATEST_PR = 1559;
47097
- var COMMITS_AHEAD_OF_TAG = 4;
47118
+ var VERSION = "0.12.20";
47119
+ var COMMIT_SHA = "5191cef3";
47120
+ var COMMIT_DATE = "2026-05-20T00:03:17Z";
47121
+ var LATEST_PR = 1565;
47122
+ var COMMITS_AHEAD_OF_TAG = 6;
47098
47123
 
47099
47124
  // gateway/boot-version.ts
47100
47125
  function formatRelativeAgo(iso) {
@@ -48071,6 +48096,12 @@ function purgeReactionTracking(key) {
48071
48096
  }
48072
48097
  }
48073
48098
  }
48099
+ function endCurrentTurnAtomic(turn) {
48100
+ if (currentTurn !== turn)
48101
+ return;
48102
+ currentTurn = null;
48103
+ purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId));
48104
+ }
48074
48105
  function maybeProactiveCompact() {
48075
48106
  if (compactDispatching)
48076
48107
  return;
@@ -48912,6 +48943,7 @@ startTimer({
48912
48943
  preambleSuppressor.dropNow();
48913
48944
  endTurn(fbKey);
48914
48945
  purgeReactionTracking(fbKey);
48946
+ const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), purgeReactionTracking);
48915
48947
  if (turnMatchesFallback && currentTurn === wedgedTurn)
48916
48948
  currentTurn = null;
48917
48949
  try {
@@ -48919,7 +48951,7 @@ startTimer({
48919
48951
  } catch {}
48920
48952
  const fbSelfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
48921
48953
  const fbRedeliver = redeliverBufferedInbound(pendingInboundBuffer, fbSelfAgent, (m) => ipcServer.sendToAgent(fbSelfAgent, m), inboundSpool);
48922
- process.stderr.write(`telegram gateway: silence-poke framework-fallback ended wedged turn chat=${fbChatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs} currentTurn_nulled=${turnMatchesFallback} drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ""}
48954
+ process.stderr.write(`telegram gateway: silence-poke framework-fallback ended wedged turn chat=${fbChatId} thread=${ctx.threadId ?? "-"} silence_ms=${ctx.silenceMs} currentTurn_nulled=${turnMatchesFallback} drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ""}${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ""}
48923
48955
  `);
48924
48956
  }
48925
48957
  });
@@ -50709,8 +50741,7 @@ function handleSessionEvent(ev) {
50709
50741
  turn.answerStream.stop();
50710
50742
  turn.answerStream = null;
50711
50743
  }
50712
- if (currentTurn === turn)
50713
- currentTurn = null;
50744
+ endCurrentTurnAtomic(turn);
50714
50745
  preambleSuppressor.reset();
50715
50746
  }
50716
50747
  return;
@@ -50827,8 +50858,7 @@ function handleSessionEvent(ev) {
50827
50858
  lastPtyPreviewByChat.delete(statusKey(chatId, threadId));
50828
50859
  pendingPtyPartial = null;
50829
50860
  closeActivityLane(chatId, threadId);
50830
- if (currentTurn === turn)
50831
- currentTurn = null;
50861
+ endCurrentTurnAtomic(turn);
50832
50862
  preambleSuppressor.dropNow();
50833
50863
  return;
50834
50864
  }
@@ -50843,8 +50873,7 @@ function handleSessionEvent(ev) {
50843
50873
  }) ?? { wasEmitted: false, turnKey: null };
50844
50874
  const backstopCardMessageId = cardTakeover.wasEmitted && cardTakeover.turnKey != null ? getPinnedProgressCardMessageId?.(cardTakeover.turnKey) ?? null : null;
50845
50875
  const backstopCardTurnKey = cardTakeover.turnKey;
50846
- if (currentTurn === turn)
50847
- currentTurn = null;
50876
+ endCurrentTurnAtomic(turn);
50848
50877
  preambleSuppressor.dropNow();
50849
50878
  {
50850
50879
  const tKey = statusKey(chatId, threadId);
@@ -51009,8 +51038,7 @@ function handleSessionEvent(ev) {
51009
51038
  }
51010
51039
  }
51011
51040
  removeTurnActiveMarker(STATE_DIR);
51012
- if (currentTurn === turn)
51013
- currentTurn = null;
51041
+ endCurrentTurnAtomic(turn);
51014
51042
  return;
51015
51043
  }
51016
51044
  }
@@ -250,6 +250,7 @@ import { handleRequestDriveApproval } from './drive-write-approval.js'
250
250
  import { buildDiffPreviewCard } from './diff-preview-card.js'
251
251
  import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
252
252
  import { createInboundSpool } from './inbound-spool.js'
253
+ import { purgeStaleTurnsForChat } from './turn-state-purge.js'
253
254
  import { decideInboundDelivery } from './inbound-delivery-gate.js'
254
255
  import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
255
256
  import {
@@ -1318,6 +1319,32 @@ function purgeReactionTracking(key: string): void {
1318
1319
  }
1319
1320
  }
1320
1321
 
1322
+ /**
1323
+ * Atomic null-and-purge for a wedged turn. Every site that ends a
1324
+ * turn by nulling `currentTurn` MUST also clear the turn's statusKey
1325
+ * from `activeTurnStartedAt` — else a dangling entry survives and
1326
+ * `#1556`'s turn-gate holds every new inbound mid-turn forever
1327
+ * (gymbro / klanker held-mid-turn symptom, 2026-05-20).
1328
+ *
1329
+ * Pre-this, three turn-end paths (silent-marker / turn-flush /
1330
+ * `turn_end`) nulled `currentTurn` on code-paths whose
1331
+ * `purgeReactionTracking` calls weren't reached on every branch,
1332
+ * leaving sibling entries under the turn's statusKey that the
1333
+ * silence-poke framework-fallback's `purgeReactionTracking(fbKey)`
1334
+ * couldn't catch (different key shape). The fallback now also sweeps
1335
+ * siblings for `fbChatId` (`turn-state-purge.ts`) as defense-in-depth,
1336
+ * but THIS helper closes the leak at origin: null and purge are
1337
+ * inseparable at every call site.
1338
+ *
1339
+ * Idempotent: a second purge is a no-op `.delete()` on a key already
1340
+ * gone — handlers that already purge elsewhere are unharmed.
1341
+ */
1342
+ function endCurrentTurnAtomic(turn: CurrentTurn): void {
1343
+ if (currentTurn !== turn) return
1344
+ currentTurn = null
1345
+ purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId))
1346
+ }
1347
+
1321
1348
  /**
1322
1349
  * Model-idle proactive-compaction check. Called ONLY from the
1323
1350
  * activeTurnStartedAt.size === 0 gate above (never mid-turn). Opt-in via
@@ -3011,6 +3038,23 @@ silencePoke.startTimer({
3011
3038
  // for this chat starts a fresh turn instead of queueing forever.
3012
3039
  silencePoke.endTurn(fbKey)
3013
3040
  purgeReactionTracking(fbKey)
3041
+ // Defense-in-depth: the fallback's purgeReactionTracking above
3042
+ // clears the canonical statusKey(chatId, threadId) for fbKey
3043
+ // only. activeTurnStartedAt can hold sibling entries for the
3044
+ // SAME chat (different threads, or a `null` vs `undefined`-thread
3045
+ // variant left over from a normal turn-end path that nulled
3046
+ // currentTurn without invoking purgeReactionTracking — the
3047
+ // gymbro/klanker held-mid-turn symptom, 2026-05-20). Any sibling
3048
+ // for fbChatId is by definition stale when THIS fallback fires
3049
+ // (the chat has been silent ≥5 min); sweep them via the same
3050
+ // purger. Multi-chat-safe — only touches keys for fbChatId, so
3051
+ // #1546's intentional cross-chat safety guard is preserved.
3052
+ // See turn-state-purge.ts.
3053
+ const fbExtraPurge = purgeStaleTurnsForChat(
3054
+ fbChatId,
3055
+ activeTurnStartedAt.keys(),
3056
+ purgeReactionTracking,
3057
+ )
3014
3058
  // Null `currentTurn` if it's still pointing at the wedged turn —
3015
3059
  // when claude eventually fires a late `turn_end` for this session
3016
3060
  // (or never does), the handler's `const turn = currentTurn` snapshot
@@ -3044,7 +3088,8 @@ silencePoke.startTimer({
3044
3088
  `chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
3045
3089
  `currentTurn_nulled=${turnMatchesFallback} ` +
3046
3090
  `drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
3047
- `${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}\n`,
3091
+ `${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
3092
+ `${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
3048
3093
  )
3049
3094
  },
3050
3095
  })
@@ -5686,7 +5731,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5686
5731
  turn.answerStream = null
5687
5732
  }
5688
5733
  // Null the atom — this turn is being abandoned.
5689
- if (currentTurn === turn) currentTurn = null
5734
+ endCurrentTurnAtomic(turn)
5690
5735
  // #549 fix — context-exhaustion teardown also resets preamble state.
5691
5736
  preambleSuppressor.reset()
5692
5737
  }
@@ -5884,7 +5929,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5884
5929
  // returns early at handler entry. A new `enqueue` swaps in a
5885
5930
  // fresh atom; the silent-turn teardown doesn't need to preserve
5886
5931
  // any of the prior turn's state.
5887
- if (currentTurn === turn) currentTurn = null
5932
+ endCurrentTurnAtomic(turn)
5888
5933
  // #549 fix — silent-marker teardown drops any pending preamble.
5889
5934
  preambleSuppressor.dropNow()
5890
5935
  return
@@ -5918,7 +5963,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5918
5963
  // sendMessage await for this turn will see currentTurn == null
5919
5964
  // and bail; a new enqueue will swap in a fresh atom. The
5920
5965
  // `backstop*` locals above hold everything the IIFE needs.
5921
- if (currentTurn === turn) currentTurn = null
5966
+ endCurrentTurnAtomic(turn)
5922
5967
  // #549 fix — turn-flush takes ownership of the captured-text
5923
5968
  // backup; reset the preamble buffer (its content is already in
5924
5969
  // the captured `capturedText`, which turn-flush is about to send).
@@ -6190,7 +6235,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6190
6235
  // #1067: null the atom in one assignment, replacing the seven
6191
6236
  // field clears the pre-refactor version did. Any late-arriving
6192
6237
  // event for this turn will see currentTurn == null and bail.
6193
- if (currentTurn === turn) currentTurn = null
6238
+ endCurrentTurnAtomic(turn)
6194
6239
  // #549 fix — preamble flush already happened at the TOP of this
6195
6240
  // turn_end handler (before turn.answerStream is nulled). See
6196
6241
  // comment near line 3431.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * turn-state-purge.ts — defense-in-depth cleanup for the silence-poke
3
+ * framework-fallback (gateway.ts).
4
+ *
5
+ * The fallback's `purgeReactionTracking(fbKey)` clears the canonical
6
+ * `statusKey(chatId, threadId)` for the exact chat+thread that armed
7
+ * the silence-poke. But `activeTurnStartedAt` (and its siblings) can
8
+ * legitimately hold MORE than one entry for the same chat — e.g.:
9
+ *
10
+ * - a normal turn-end handler null'd `currentTurn` but skipped
11
+ * `purgeReactionTracking` on a code-path that didn't reach it
12
+ * (gateway.ts:5887/5921/6193 — purgeReactionTracking calls in
13
+ * those handlers are conditional/not on every branch), so
14
+ * `activeTurnStartedAt[oldKey]` is dangling when the fallback
15
+ * fires for a NEW key
16
+ * - a multi-thread chat (topic-based group, or a `null` vs
17
+ * `undefined` thread variant) has entries under different keys of
18
+ * the SAME chat
19
+ *
20
+ * In either case, the fallback purges `fbKey` but a sibling key for
21
+ * the same chat remains → `activeTurnStartedAt.size > 0` persists →
22
+ * `#1556`'s turn-gate holds every new inbound forever → user sees
23
+ * "not responding" while claude is actually idle (gymbro + klanker,
24
+ * 2026-05-20).
25
+ *
26
+ * This helper sweeps any sibling keys for the firing chat after the
27
+ * canonical `purgeReactionTracking(fbKey)`, via the same purger.
28
+ * Multi-chat safe: it only touches keys whose chatId matches — other
29
+ * chats' active turns are untouched, preserving the deliberate
30
+ * multi-chat safety #1546 kept.
31
+ *
32
+ * Key shape: `statusKey(chatId, threadId)` emits
33
+ * `${chatId}:${threadId ?? '_'}`. Chat ids are numeric strings (no
34
+ * `:` inside), so `indexOf(':')` is a safe prefix delimiter. A
35
+ * "prefix-without-separator" false positive (e.g. chatId `123` vs key
36
+ * `1234:_`) is structurally impossible because the helper splits on
37
+ * `:` and equates the prefix, NOT a string-startsWith.
38
+ *
39
+ * Pure / dependency-free so the multi-chat-safety and key-shape logic
40
+ * are unit-testable without standing up a gateway — mirrors the
41
+ * `#1544 / #1546 / #1549 / #1558` pure-seam idiom.
42
+ */
43
+
44
+ export interface PurgeStaleTurnsResult {
45
+ /** Keys for `chatId` that the helper purged via the callback. */
46
+ purged: string[]
47
+ }
48
+
49
+ export function purgeStaleTurnsForChat(
50
+ chatId: string,
51
+ keys: Iterable<string>,
52
+ purger: (key: string) => void,
53
+ ): PurgeStaleTurnsResult {
54
+ if (!chatId) return { purged: [] }
55
+ const purged: string[] = []
56
+ // Snapshot first — `purger` typically deletes from the same Map the
57
+ // caller is iterating; mutating during iteration is correct on JS
58
+ // Maps but a snapshot makes the contract explicit and lets the
59
+ // helper accept any iterable.
60
+ const snapshot: string[] = []
61
+ for (const k of keys) snapshot.push(k)
62
+ for (const key of snapshot) {
63
+ const sep = key.indexOf(':')
64
+ if (sep < 0) continue // malformed / non-statusKey shape — skip
65
+ const keyChat = key.slice(0, sep)
66
+ if (keyChat !== chatId) continue
67
+ purger(key)
68
+ purged.push(key)
69
+ }
70
+ return { purged }
71
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * turn-state-purge — the silence-poke fallback's defense-in-depth
3
+ * cleanup. Pins the multi-chat safety + key-shape correctness that
4
+ * the gymbro/klanker held-mid-turn fix (2026-05-20) depends on.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest'
8
+ import { purgeStaleTurnsForChat } from '../gateway/turn-state-purge.js'
9
+
10
+ function statusKey(chatId: string, threadId?: number): string {
11
+ return `${chatId}:${threadId ?? '_'}`
12
+ }
13
+
14
+ describe('purgeStaleTurnsForChat', () => {
15
+ it('returns [] and calls nothing when keys is empty', () => {
16
+ let calls = 0
17
+ const r = purgeStaleTurnsForChat('123', [], () => calls++)
18
+ expect(r.purged).toEqual([])
19
+ expect(calls).toBe(0)
20
+ })
21
+
22
+ it('returns [] and calls nothing when chatId is empty (guard)', () => {
23
+ let calls = 0
24
+ const r = purgeStaleTurnsForChat('', [statusKey('123')], () => calls++)
25
+ expect(r.purged).toEqual([])
26
+ expect(calls).toBe(0)
27
+ })
28
+
29
+ it('purges the canonical DM key for the firing chat (typical case)', () => {
30
+ const purged: string[] = []
31
+ const r = purgeStaleTurnsForChat(
32
+ '12345',
33
+ [statusKey('12345')],
34
+ (k) => purged.push(k),
35
+ )
36
+ expect(r.purged).toEqual(['12345:_'])
37
+ expect(purged).toEqual(['12345:_'])
38
+ })
39
+
40
+ it('purges every sibling key for the same chat (multi-thread / dangling)', () => {
41
+ // This is the gymbro/klanker symptom: a stale activeTurnStartedAt
42
+ // entry under a different thread of the same chat survives the
43
+ // canonical purgeReactionTracking(fbKey).
44
+ const purged: string[] = []
45
+ const r = purgeStaleTurnsForChat(
46
+ '12345',
47
+ [
48
+ statusKey('12345'), // DM, no thread
49
+ statusKey('12345', 42), // thread 42
50
+ statusKey('12345', 99), // thread 99
51
+ ],
52
+ (k) => purged.push(k),
53
+ )
54
+ expect(r.purged.sort()).toEqual(['12345:42', '12345:99', '12345:_'])
55
+ expect(purged).toHaveLength(3)
56
+ })
57
+
58
+ it('NEVER touches keys for OTHER chats (multi-chat safety — the #1546 guard)', () => {
59
+ const purged: string[] = []
60
+ const r = purgeStaleTurnsForChat(
61
+ '12345',
62
+ [
63
+ statusKey('12345'), // target chat — purge
64
+ statusKey('-1001234567890'), // different chat — must NOT touch
65
+ statusKey('-1001234567890', 7), // different chat thread — must NOT touch
66
+ ],
67
+ (k) => purged.push(k),
68
+ )
69
+ expect(r.purged).toEqual(['12345:_'])
70
+ expect(purged).toEqual(['12345:_']) // ONLY the target chat
71
+ })
72
+
73
+ it('does not false-match on a chatId-prefix superstring (e.g. 123 vs 1234)', () => {
74
+ // If we used a naive startsWith on the WHOLE key, chatId "123"
75
+ // would falsely match key "1234:_". The helper splits on `:` and
76
+ // equates the chatId slice, which prevents that.
77
+ const purged: string[] = []
78
+ const r = purgeStaleTurnsForChat(
79
+ '123',
80
+ [statusKey('123'), statusKey('1234'), statusKey('12')],
81
+ (k) => purged.push(k),
82
+ )
83
+ expect(r.purged).toEqual(['123:_']) // ONLY the exact match
84
+ })
85
+
86
+ it('skips malformed keys (no `:`)', () => {
87
+ let calls = 0
88
+ const r = purgeStaleTurnsForChat(
89
+ '123',
90
+ ['malformed', '', 'no-colon-here', statusKey('123')],
91
+ () => calls++,
92
+ )
93
+ expect(r.purged).toEqual(['123:_'])
94
+ expect(calls).toBe(1)
95
+ })
96
+
97
+ it('accepts any iterable (snapshots before iteration — purger may delete)', () => {
98
+ // The real call site iterates `activeTurnStartedAt.keys()` and
99
+ // the purger deletes from that same Map. Snapshotting in the
100
+ // helper prevents iterator skew.
101
+ const map = new Map<string, number>()
102
+ map.set('123:_', 1)
103
+ map.set('123:7', 2)
104
+ map.set('999:_', 3)
105
+ const r = purgeStaleTurnsForChat('123', map.keys(), (k) => map.delete(k))
106
+ expect(r.purged.sort()).toEqual(['123:7', '123:_'])
107
+ expect([...map.keys()]).toEqual(['999:_']) // multi-chat safety preserved
108
+ })
109
+ })