switchroom 0.12.19 → 0.12.21

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.21",
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,12 +26,13 @@
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 && node scripts/check-no-broadcast-delivery.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",
33
33
  "lint:bun-test-imports": "node scripts/check-bun-test-imports.mjs",
34
34
  "lint:no-pii": "node scripts/check-no-pii-secrets.mjs",
35
+ "lint:no-broadcast-delivery": "node scripts/check-no-broadcast-delivery.mjs",
35
36
  "prepublishOnly": "npm run build && npm run lint && npm test"
36
37
  },
37
38
  "dependencies": {
@@ -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."),
@@ -31457,7 +31461,8 @@ function createStreamController(cfg) {
31457
31461
 
31458
31462
  // pty-partial-handler.ts
31459
31463
  function streamKey(chatId, threadId) {
31460
- return `${chatId}:${threadId ?? "_"}`;
31464
+ const t = threadId == null || threadId === 0 ? "_" : String(threadId);
31465
+ return `${chatId}:${t}`;
31461
31466
  }
31462
31467
  function handlePtyPartialPure(text, state, deps) {
31463
31468
  if (state.currentSessionChatId == null) {
@@ -31543,7 +31548,8 @@ function buildAccentHeader(accent) {
31543
31548
  }
31544
31549
  }
31545
31550
  function streamKey2(chatId, threadId, lane, turnKey) {
31546
- const base = `${chatId}:${threadId ?? "_"}`;
31551
+ const t = threadId == null || threadId === 0 ? "_" : String(threadId);
31552
+ const base = `${chatId}:${t}`;
31547
31553
  const withLane = lane != null && lane.length > 0 ? `${base}:${lane}` : base;
31548
31554
  return turnKey != null && turnKey.length > 0 ? `${withLane}:${turnKey}` : withLane;
31549
31555
  }
@@ -43725,6 +43731,27 @@ function createInboundSpool(opts) {
43725
43731
  };
43726
43732
  }
43727
43733
 
43734
+ // gateway/turn-state-purge.ts
43735
+ function purgeStaleTurnsForChat(chatId, keys, purger) {
43736
+ if (!chatId)
43737
+ return { purged: [] };
43738
+ const purged = [];
43739
+ const snapshot = [];
43740
+ for (const k of keys)
43741
+ snapshot.push(k);
43742
+ for (const key of snapshot) {
43743
+ const sep3 = key.indexOf(":");
43744
+ if (sep3 < 0)
43745
+ continue;
43746
+ const keyChat = key.slice(0, sep3);
43747
+ if (keyChat !== chatId)
43748
+ continue;
43749
+ purger(key);
43750
+ purged.push(key);
43751
+ }
43752
+ return { purged };
43753
+ }
43754
+
43728
43755
  // gateway/inbound-delivery-gate.ts
43729
43756
  function decideInboundDelivery(input) {
43730
43757
  if (input.turnInFlight && !input.isSteering)
@@ -43778,6 +43805,15 @@ function createPendingPermissionBuffer(opts = {}) {
43778
43805
  };
43779
43806
  }
43780
43807
 
43808
+ // gateway/chat-key.ts
43809
+ function chatKey(chatId, threadId) {
43810
+ const t = threadId == null || threadId === 0 ? "_" : String(threadId);
43811
+ return `${chatId}:${t}`;
43812
+ }
43813
+ function chatKeyWithSuffix(chatId, threadId, suffix) {
43814
+ return `${chatKey(chatId, threadId)}:${suffix}`;
43815
+ }
43816
+
43781
43817
  // gateway/vault-grant-inbound-builders.ts
43782
43818
  function buildVaultGrantApprovedInbound(opts) {
43783
43819
  const ts = opts.nowMs ?? Date.now();
@@ -47090,11 +47126,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47090
47126
  }
47091
47127
 
47092
47128
  // ../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;
47129
+ var VERSION = "0.12.21";
47130
+ var COMMIT_SHA = "e32c064";
47131
+ var COMMIT_DATE = "2026-05-20T02:05:49Z";
47132
+ var LATEST_PR = 1572;
47133
+ var COMMITS_AHEAD_OF_TAG = null;
47098
47134
 
47099
47135
  // gateway/boot-version.ts
47100
47136
  function formatRelativeAgo(iso) {
@@ -48037,10 +48073,10 @@ var CONTEXT_EXHAUSTION_COOLDOWN_MS = 600000;
48037
48073
  var lastContextExhaustionWarningAt = 0;
48038
48074
  var pendingPtyPartial = null;
48039
48075
  function statusKey(chatId, threadId) {
48040
- return `${chatId}:${threadId ?? "_"}`;
48076
+ return chatKey(chatId, threadId);
48041
48077
  }
48042
48078
  function streamKey3(chatId, threadId) {
48043
- return `${chatId}:${threadId ?? "_"}`;
48079
+ return chatKey(chatId, threadId);
48044
48080
  }
48045
48081
  function purgeReactionTracking(key) {
48046
48082
  const msgInfo = activeReactionMsgIds.get(key);
@@ -48071,6 +48107,12 @@ function purgeReactionTracking(key) {
48071
48107
  }
48072
48108
  }
48073
48109
  }
48110
+ function endCurrentTurnAtomic(turn) {
48111
+ if (currentTurn !== turn)
48112
+ return;
48113
+ currentTurn = null;
48114
+ purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId));
48115
+ }
48074
48116
  function maybeProactiveCompact() {
48075
48117
  if (compactDispatching)
48076
48118
  return;
@@ -48912,6 +48954,7 @@ startTimer({
48912
48954
  preambleSuppressor.dropNow();
48913
48955
  endTurn(fbKey);
48914
48956
  purgeReactionTracking(fbKey);
48957
+ const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), purgeReactionTracking);
48915
48958
  if (turnMatchesFallback && currentTurn === wedgedTurn)
48916
48959
  currentTurn = null;
48917
48960
  try {
@@ -48919,7 +48962,7 @@ startTimer({
48919
48962
  } catch {}
48920
48963
  const fbSelfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
48921
48964
  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}` : ""}
48965
+ 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
48966
  `);
48924
48967
  }
48925
48968
  });
@@ -50494,7 +50537,7 @@ function resetOrphanedReplyTimeout() {
50494
50537
  }
50495
50538
  }
50496
50539
  function closeActivityLane(chatId, threadId) {
50497
- const key = `${chatId}:${threadId ?? "_"}:activity`;
50540
+ const key = chatKeyWithSuffix(chatId, threadId, "activity");
50498
50541
  const stream = activeDraftStreams.get(key);
50499
50542
  if (stream == null)
50500
50543
  return;
@@ -50503,7 +50546,7 @@ function closeActivityLane(chatId, threadId) {
50503
50546
  stream.finalize().catch(() => {});
50504
50547
  }
50505
50548
  function closeProgressLane(chatId, threadId) {
50506
- const prefix = `${chatId}:${threadId ?? "_"}:progress`;
50549
+ const prefix = chatKeyWithSuffix(chatId, threadId, "progress");
50507
50550
  for (const [key, stream] of activeDraftStreams) {
50508
50551
  if (key.startsWith(prefix)) {
50509
50552
  activeDraftStreams.delete(key);
@@ -50543,7 +50586,8 @@ function handleSessionEvent(ev) {
50543
50586
  currentTurn = next;
50544
50587
  preambleSuppressor.reset();
50545
50588
  if (turnsDb != null) {
50546
- const turnKey = `${ev.chatId}:${ev.threadId ?? "_"}:${startedAt}`;
50589
+ const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null;
50590
+ const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt));
50547
50591
  next.registryKey = turnKey;
50548
50592
  const userPromptPreview = extractUserPromptPreview(ev.rawContent);
50549
50593
  try {
@@ -50709,8 +50753,7 @@ function handleSessionEvent(ev) {
50709
50753
  turn.answerStream.stop();
50710
50754
  turn.answerStream = null;
50711
50755
  }
50712
- if (currentTurn === turn)
50713
- currentTurn = null;
50756
+ endCurrentTurnAtomic(turn);
50714
50757
  preambleSuppressor.reset();
50715
50758
  }
50716
50759
  return;
@@ -50777,7 +50820,7 @@ function handleSessionEvent(ev) {
50777
50820
  if (flushDecision.kind === "skip" && flushDecision.reason === "silent-marker") {
50778
50821
  process.stderr.write(`telegram gateway: silent-turn-suppression: chat=${chatId} turnKey=${turn.startedAt} reason=silent-marker
50779
50822
  `);
50780
- const suppressPrefix = `${chatId}:${threadId ?? "_"}:progress`;
50823
+ const suppressPrefix = chatKeyWithSuffix(chatId, threadId, "progress");
50781
50824
  for (const [key] of activeDraftStreams) {
50782
50825
  if (key.startsWith(suppressPrefix)) {
50783
50826
  activeDraftStreams.delete(key);
@@ -50827,8 +50870,7 @@ function handleSessionEvent(ev) {
50827
50870
  lastPtyPreviewByChat.delete(statusKey(chatId, threadId));
50828
50871
  pendingPtyPartial = null;
50829
50872
  closeActivityLane(chatId, threadId);
50830
- if (currentTurn === turn)
50831
- currentTurn = null;
50873
+ endCurrentTurnAtomic(turn);
50832
50874
  preambleSuppressor.dropNow();
50833
50875
  return;
50834
50876
  }
@@ -50843,8 +50885,7 @@ function handleSessionEvent(ev) {
50843
50885
  }) ?? { wasEmitted: false, turnKey: null };
50844
50886
  const backstopCardMessageId = cardTakeover.wasEmitted && cardTakeover.turnKey != null ? getPinnedProgressCardMessageId?.(cardTakeover.turnKey) ?? null : null;
50845
50887
  const backstopCardTurnKey = cardTakeover.turnKey;
50846
- if (currentTurn === turn)
50847
- currentTurn = null;
50888
+ endCurrentTurnAtomic(turn);
50848
50889
  preambleSuppressor.dropNow();
50849
50890
  {
50850
50891
  const tKey = statusKey(chatId, threadId);
@@ -51009,8 +51050,7 @@ function handleSessionEvent(ev) {
51009
51050
  }
51010
51051
  }
51011
51052
  removeTurnActiveMarker(STATE_DIR);
51012
- if (currentTurn === turn)
51013
- currentTurn = null;
51053
+ endCurrentTurnAtomic(turn);
51014
51054
  return;
51015
51055
  }
51016
51056
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Canonical chat-key construction with branded type.
3
+ *
4
+ * The gateway tracks per-chat state (active turn, draft stream, status
5
+ * reactions, progress throttle, etc.) in `Map<string, ...>` instances
6
+ * keyed by a string derived from `(chatId, threadId | null)`. Multiple
7
+ * callsites historically constructed those keys inline as
8
+ * `` `${chatId}:${threadId ?? '_'}` ``. That worked until PR #1564 hit
9
+ * the silence-poke wedge: an `activeTurnStartedAt` entry survived a
10
+ * turn-end because the entry was set under one rendering of the key
11
+ * (`null` thread → `_`) and the cleanup ran against another
12
+ * (`undefined` thread → `_`, but also `0` thread → `0`, which doesn't
13
+ * coalesce). The fallback's `purgeReactionTracking` cleared one
14
+ * statusKey at a time; sibling keys for the same chat persisted, so
15
+ * `activeTurnStartedAt.size > 0` stayed true forever and every
16
+ * subsequent inbound was held mid-turn.
17
+ *
18
+ * This module makes the bug class hard to reintroduce:
19
+ *
20
+ * 1. `chatKey()` is the canonical constructor for "this chat+thread"
21
+ * keys. It collapses `null`, `undefined`, and `0` thread IDs
22
+ * into the same token (`_`). All sites in `gateway.ts`,
23
+ * `stream-reply-handler.ts`, and `pty-partial-handler.ts` route
24
+ * through it (or mirror its expression inline).
25
+ * 2. The returned `ChatKey` is a branded string — a documentation
26
+ * marker for human readers. `telegram-plugin/` is bun-bundled
27
+ * and NOT in `tsconfig.json`'s `include`; `lint:plugin-references`
28
+ * filters tsc output to reference-class errors only (TS2304/2552/
29
+ * 2722/2561). A future opt-in `Map<ChatKey, T>` migration could
30
+ * provide enforcement teeth, but that requires either bringing
31
+ * `telegram-plugin/` into the strict tsc check or rewriting the
32
+ * lint to fail on TS2345 type-mismatch. Today the brand is a
33
+ * nudge, not a fence.
34
+ *
35
+ * `chatKeyWithSuffix()` extends a ChatKey with a lane suffix
36
+ * (`activity`, `progress`, etc.) — same documentation brand.
37
+ *
38
+ * Non-ChatKey keyspaces (chat:message, chat:user, chat:sender:reason)
39
+ * are deliberately NOT branded — they have different shape and
40
+ * different invariants, see `deferredKey` / `inboundCoalesceKey` /
41
+ * the gate-dedup helper in `gateway.ts`.
42
+ */
43
+
44
+ export type ChatKey = string & { readonly __brand: 'ChatKey' }
45
+
46
+ export function chatKey(chatId: string, threadId?: number | null): ChatKey {
47
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
48
+ return `${chatId}:${t}` as ChatKey
49
+ }
50
+
51
+ export function chatKeyWithSuffix(
52
+ chatId: string,
53
+ threadId: number | null | undefined,
54
+ suffix: string,
55
+ ): ChatKey {
56
+ return `${chatKey(chatId, threadId)}:${suffix}` as ChatKey
57
+ }
58
+
59
+ /**
60
+ * Extract the chatId portion of a ChatKey. Used by sibling-key sweeps
61
+ * (PR #1564's `purgeStaleTurnsForChat`) that need to enumerate every
62
+ * thread under a single chat.
63
+ */
64
+ export function chatIdOfChatKey(key: ChatKey): string {
65
+ const idx = key.indexOf(':')
66
+ return idx === -1 ? key : key.slice(0, idx)
67
+ }
@@ -250,8 +250,10 @@ 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'
256
+ import { chatKey, chatKeyWithSuffix } from './chat-key.js'
255
257
  import {
256
258
  buildVaultGrantApprovedInbound,
257
259
  buildVaultGrantDeniedInbound,
@@ -1025,6 +1027,7 @@ function checkApprovals(): void {
1025
1027
  try { files = readdirSync(APPROVED_DIR) } catch { return }
1026
1028
  for (const senderId of files) {
1027
1029
  const file = join(APPROVED_DIR, senderId)
1030
+ // allow-raw-bot-api: Paired! sendMessage to DM senderId; no thread_id, cannot trigger THREAD_NOT_FOUND
1028
1031
  void bot.api.sendMessage(senderId, "Paired! Say hi to Claude.").then(
1029
1032
  () => rmSync(file, { force: true }),
1030
1033
  err => {
@@ -1247,12 +1250,18 @@ let lastContextExhaustionWarningAt = 0
1247
1250
 
1248
1251
  let pendingPtyPartial: string | null = null
1249
1252
 
1250
- function statusKey(chatId: string, threadId?: number): string {
1251
- return `${chatId}:${threadId ?? '_'}`
1253
+ // statusKey + streamKey are thin re-exports of the canonical chatKey()
1254
+ // constructor (see telegram-plugin/gateway/chat-key.ts). chatKey()
1255
+ // collapses 0/null/undefined thread IDs to the same token (`_`),
1256
+ // closing the sibling-key class behind #1564 (where one site set the
1257
+ // entry under `null → _` and another cleared under `0 → 0`, leaving
1258
+ // activeTurnStartedAt non-empty forever).
1259
+ function statusKey(chatId: string, threadId?: number | null): string {
1260
+ return chatKey(chatId, threadId)
1252
1261
  }
1253
1262
 
1254
- function streamKey(chatId: string, threadId?: number): string {
1255
- return `${chatId}:${threadId ?? '_'}`
1263
+ function streamKey(chatId: string, threadId?: number | null): string {
1264
+ return chatKey(chatId, threadId)
1256
1265
  }
1257
1266
 
1258
1267
  function purgeReactionTracking(key: string): void {
@@ -1318,6 +1327,32 @@ function purgeReactionTracking(key: string): void {
1318
1327
  }
1319
1328
  }
1320
1329
 
1330
+ /**
1331
+ * Atomic null-and-purge for a wedged turn. Every site that ends a
1332
+ * turn by nulling `currentTurn` MUST also clear the turn's statusKey
1333
+ * from `activeTurnStartedAt` — else a dangling entry survives and
1334
+ * `#1556`'s turn-gate holds every new inbound mid-turn forever
1335
+ * (gymbro / klanker held-mid-turn symptom, 2026-05-20).
1336
+ *
1337
+ * Pre-this, three turn-end paths (silent-marker / turn-flush /
1338
+ * `turn_end`) nulled `currentTurn` on code-paths whose
1339
+ * `purgeReactionTracking` calls weren't reached on every branch,
1340
+ * leaving sibling entries under the turn's statusKey that the
1341
+ * silence-poke framework-fallback's `purgeReactionTracking(fbKey)`
1342
+ * couldn't catch (different key shape). The fallback now also sweeps
1343
+ * siblings for `fbChatId` (`turn-state-purge.ts`) as defense-in-depth,
1344
+ * but THIS helper closes the leak at origin: null and purge are
1345
+ * inseparable at every call site.
1346
+ *
1347
+ * Idempotent: a second purge is a no-op `.delete()` on a key already
1348
+ * gone — handlers that already purge elsewhere are unharmed.
1349
+ */
1350
+ function endCurrentTurnAtomic(turn: CurrentTurn): void {
1351
+ if (currentTurn !== turn) return
1352
+ currentTurn = null
1353
+ purgeReactionTracking(statusKey(turn.sessionChatId, turn.sessionThreadId))
1354
+ }
1355
+
1321
1356
  /**
1322
1357
  * Model-idle proactive-compaction check. Called ONLY from the
1323
1358
  * activeTurnStartedAt.size === 0 gate above (never mid-turn). Opt-in via
@@ -2648,6 +2683,7 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
2648
2683
  parse_mode: 'HTML' as const,
2649
2684
  ...(renderedKeyboard ? { reply_markup: renderedKeyboard } : {}),
2650
2685
  }
2686
+ // allow-raw-bot-api: operator-event broadcast loop; opts has no message_thread_id
2651
2687
  void bot.api.sendMessage(chat_id, renderedText, opts as never).catch(e => {
2652
2688
  process.stderr.write(
2653
2689
  `telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
@@ -3011,6 +3047,23 @@ silencePoke.startTimer({
3011
3047
  // for this chat starts a fresh turn instead of queueing forever.
3012
3048
  silencePoke.endTurn(fbKey)
3013
3049
  purgeReactionTracking(fbKey)
3050
+ // Defense-in-depth: the fallback's purgeReactionTracking above
3051
+ // clears the canonical statusKey(chatId, threadId) for fbKey
3052
+ // only. activeTurnStartedAt can hold sibling entries for the
3053
+ // SAME chat (different threads, or a `null` vs `undefined`-thread
3054
+ // variant left over from a normal turn-end path that nulled
3055
+ // currentTurn without invoking purgeReactionTracking — the
3056
+ // gymbro/klanker held-mid-turn symptom, 2026-05-20). Any sibling
3057
+ // for fbChatId is by definition stale when THIS fallback fires
3058
+ // (the chat has been silent ≥5 min); sweep them via the same
3059
+ // purger. Multi-chat-safe — only touches keys for fbChatId, so
3060
+ // #1546's intentional cross-chat safety guard is preserved.
3061
+ // See turn-state-purge.ts.
3062
+ const fbExtraPurge = purgeStaleTurnsForChat(
3063
+ fbChatId,
3064
+ activeTurnStartedAt.keys(),
3065
+ purgeReactionTracking,
3066
+ )
3014
3067
  // Null `currentTurn` if it's still pointing at the wedged turn —
3015
3068
  // when claude eventually fires a late `turn_end` for this session
3016
3069
  // (or never does), the handler's `const turn = currentTurn` snapshot
@@ -3044,7 +3097,8 @@ silencePoke.startTimer({
3044
3097
  `chat=${fbChatId} thread=${ctx.threadId ?? '-'} silence_ms=${ctx.silenceMs} ` +
3045
3098
  `currentTurn_nulled=${turnMatchesFallback} ` +
3046
3099
  `drained_buffered=${fbRedeliver.redelivered}/${fbRedeliver.drained}` +
3047
- `${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}\n`,
3100
+ `${fbRedeliver.rebuffered > 0 ? ` rebuffered=${fbRedeliver.rebuffered}` : ''}` +
3101
+ `${fbExtraPurge.purged.length > 0 ? ` extra_keys_purged=${fbExtraPurge.purged.length}` : ''}\n`,
3048
3102
  )
3049
3103
  },
3050
3104
  })
@@ -3408,6 +3462,7 @@ const ipcServer: IpcServer = createIpcServer({
3408
3462
  .text(`🔁 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`)
3409
3463
  }
3410
3464
  for (const chat_id of access.allowFrom) {
3465
+ // allow-raw-bot-api: permission-request keyboard fan-out; reply_markup-only opts, no thread_id
3411
3466
  void bot.api.sendMessage(chat_id, text, { reply_markup: keyboard }).catch(e => {
3412
3467
  process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
3413
3468
  })
@@ -4024,6 +4079,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4024
4079
  stripped.length > 0
4025
4080
  ? stripped
4026
4081
  : '⚠️ (a formatted fragment could not be rendered for Telegram)'
4082
+ // allow-raw-bot-api: plaintext last-resort fallback; wrapping would re-enter the parse-mode policy that just rejected the payload
4027
4083
  const sent = await lockedBot.api.sendMessage(chat_id, plain, plainOpts as never)
4028
4084
  sentIds.push(sent.message_id)
4029
4085
  logOutbound('reply', chat_id, sent.message_id, plain.length, `chunk=${i + 1}/${chunks.length} plaintext-fallback`)
@@ -4045,6 +4101,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
4045
4101
  const retryOpts = { ...sendOpts }
4046
4102
  delete (retryOpts as any).message_thread_id
4047
4103
  try {
4104
+ // allow-raw-bot-api: chunk-loop THREAD_NOT_FOUND fallback; thread already dropped, wrapping would re-enter the throw
4048
4105
  const sent = await lockedBot.api.sendMessage(chat_id, chunks[i], retryOpts as never)
4049
4106
  sentIds.push(sent.message_id)
4050
4107
  } catch (retryErr) {
@@ -5347,7 +5404,7 @@ function resetOrphanedReplyTimeout(): void {
5347
5404
  }
5348
5405
 
5349
5406
  function closeActivityLane(chatId: string, threadId: number | undefined): void {
5350
- const key = `${chatId}:${threadId ?? '_'}:activity`
5407
+ const key = chatKeyWithSuffix(chatId, threadId, 'activity')
5351
5408
  const stream = activeDraftStreams.get(key)
5352
5409
  if (stream == null) return
5353
5410
  activeDraftStreams.delete(key)
@@ -5359,7 +5416,7 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
5359
5416
  // Progress-card streams include a turnKey suffix in their key
5360
5417
  // (e.g. "chatId:_:progress:chatId:1"). Iterate and match by prefix
5361
5418
  // so the backstop actually finds the stream.
5362
- const prefix = `${chatId}:${threadId ?? '_'}:progress`
5419
+ const prefix = chatKeyWithSuffix(chatId, threadId, 'progress')
5363
5420
  for (const [key, stream] of activeDraftStreams) {
5364
5421
  if (key.startsWith(prefix)) {
5365
5422
  activeDraftStreams.delete(key)
@@ -5415,7 +5472,11 @@ function handleSessionEvent(ev: SessionEvent): void {
5415
5472
  // progress-card-driver's per-chat sequence number (these are two
5416
5473
  // independent identifier schemes and don't need to align).
5417
5474
  if (turnsDb != null) {
5418
- const turnKey = `${ev.chatId}:${ev.threadId ?? '_'}:${startedAt}`
5475
+ // ev.threadId is `string | null` (Telegram emits as string); convert
5476
+ // to number for chatKeyWithSuffix. Number(null) = 0 which canonicalizes
5477
+ // to '_' — same as the explicit `null` branch below.
5478
+ const evThreadIdNum = ev.threadId != null ? Number(ev.threadId) : null
5479
+ const turnKey = chatKeyWithSuffix(ev.chatId, evThreadIdNum, String(startedAt))
5419
5480
  next.registryKey = turnKey
5420
5481
  // Phase 1 of #332: capture first ~200 chars of the user's message.
5421
5482
  const userPromptPreview = extractUserPromptPreview(ev.rawContent)
@@ -5686,7 +5747,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5686
5747
  turn.answerStream = null
5687
5748
  }
5688
5749
  // Null the atom — this turn is being abandoned.
5689
- if (currentTurn === turn) currentTurn = null
5750
+ endCurrentTurnAtomic(turn)
5690
5751
  // #549 fix — context-exhaustion teardown also resets preamble state.
5691
5752
  preambleSuppressor.reset()
5692
5753
  }
@@ -5812,7 +5873,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5812
5873
  // Drop progress-card streams without finalising — the normal
5813
5874
  // closeProgressLane call below would call stream.finalize() which
5814
5875
  // sends a final "Done" edit to Telegram. Skip that for silent turns.
5815
- const suppressPrefix = `${chatId}:${threadId ?? '_'}:progress`
5876
+ const suppressPrefix = chatKeyWithSuffix(chatId, threadId, 'progress')
5816
5877
  for (const [key] of activeDraftStreams) {
5817
5878
  if (key.startsWith(suppressPrefix)) {
5818
5879
  activeDraftStreams.delete(key)
@@ -5884,7 +5945,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5884
5945
  // returns early at handler entry. A new `enqueue` swaps in a
5885
5946
  // fresh atom; the silent-turn teardown doesn't need to preserve
5886
5947
  // any of the prior turn's state.
5887
- if (currentTurn === turn) currentTurn = null
5948
+ endCurrentTurnAtomic(turn)
5888
5949
  // #549 fix — silent-marker teardown drops any pending preamble.
5889
5950
  preambleSuppressor.dropNow()
5890
5951
  return
@@ -5918,7 +5979,7 @@ function handleSessionEvent(ev: SessionEvent): void {
5918
5979
  // sendMessage await for this turn will see currentTurn == null
5919
5980
  // and bail; a new enqueue will swap in a fresh atom. The
5920
5981
  // `backstop*` locals above hold everything the IIFE needs.
5921
- if (currentTurn === turn) currentTurn = null
5982
+ endCurrentTurnAtomic(turn)
5922
5983
  // #549 fix — turn-flush takes ownership of the captured-text
5923
5984
  // backup; reset the preamble buffer (its content is already in
5924
5985
  // the captured `capturedText`, which turn-flush is about to send).
@@ -6190,7 +6251,7 @@ function handleSessionEvent(ev: SessionEvent): void {
6190
6251
  // #1067: null the atom in one assignment, replacing the seven
6191
6252
  // field clears the pre-refactor version did. Any late-arriving
6192
6253
  // event for this turn will see currentTurn == null and bail.
6193
- if (currentTurn === turn) currentTurn = null
6254
+ endCurrentTurnAtomic(turn)
6194
6255
  // #549 fix — preamble flush already happened at the TOP of this
6195
6256
  // turn_end handler (before turn.answerStream is nulled). See
6196
6257
  // comment near line 3431.
@@ -10933,6 +10994,7 @@ async function grantWizardStep2(ctx: Context, chatId: string, agent: string, wiz
10933
10994
  const kb = buildGrantKeysKeyboard(keys, selected)
10934
10995
  const text = `<b>Grant capability token — Step 2/3</b>\n\nWhich keys for <code>${escapeHtmlForTg(agent)}</code>?\n<i>Tap to toggle; tap Continue when done.</i>`
10935
10996
  if (wizardMsgId != null) {
10997
+ // allow-raw-bot-api: vault grant wizard step 2/3; already .catch-swallows, tap-driven UI re-renders on retry
10936
10998
  await ctx.api.editMessageText(chatId, wizardMsgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
10937
10999
  } else {
10938
11000
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
@@ -10956,6 +11018,7 @@ async function grantWizardStep3(ctx: Context, chatId: string, state: Extract<Pen
10956
11018
  const text = `<b>Grant capability token — Step 3/3</b>\n\nKeys for <code>${escapeHtmlForTg(state.agent!)}</code>:\n${keyList}\n\nHow long should this grant be valid?`
10957
11019
  const msgId = state.wizardMsgId
10958
11020
  if (msgId != null) {
11021
+ // allow-raw-bot-api: vault grant wizard step 3/3 (TTL select); already .catch-swallows, tap-driven UI re-renders on retry
10959
11022
  await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
10960
11023
  } else {
10961
11024
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
@@ -10980,6 +11043,7 @@ async function grantWizardConfirm(ctx: Context, chatId: string, state: Extract<P
10980
11043
  ].join('\n')
10981
11044
  const msgId = state.wizardMsgId
10982
11045
  if (msgId != null) {
11046
+ // allow-raw-bot-api: vault grant wizard confirm step; already .catch-swallows, tap-driven UI re-renders on retry
10983
11047
  await ctx.api.editMessageText(chatId, msgId, text, { parse_mode: 'HTML', reply_markup: kb }).catch(() => {})
10984
11048
  } else {
10985
11049
  const sent = await switchroomReply(ctx, text, { html: true, reply_markup: kb })
@@ -13417,6 +13481,7 @@ function handleChecklistUpdate(
13417
13481
  ts: new Date(ts * 1000).toISOString(),
13418
13482
  },
13419
13483
  }
13484
+ // allow-broadcast: informational checklist task notification, not turn-driving
13420
13485
  ipcServer.broadcast(inboundMsg)
13421
13486
  process.stderr.write(
13422
13487
  `telegram gateway: checklist ${kind}: chat_id=${chat_id} message_id=${message_id} task_id=${taskId} state=${state} user=${userName}\n`,
@@ -13868,7 +13933,7 @@ async function shutdown(signal: string): Promise<void> {
13868
13933
  // gateway no longer pre-allocates drafts on inbound, so there is
13869
13934
  // nothing to clear at SIGTERM time.
13870
13935
 
13871
- // Notify bridges so they can mark themselves disconnected.
13936
+ // allow-broadcast: informational shutdown notify bridges mark themselves disconnected
13872
13937
  ipcServer.broadcast({ type: 'status', status: 'gateway_shutting_down' })
13873
13938
 
13874
13939
  // Hard force-exit safety net at budget + 5s. systemd's TimeoutStopSec