switchroom 0.18.11 → 0.18.13

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.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.18.11",
4
+ "version": "0.18.13",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -24,9 +24,9 @@
24
24
  "build": "node scripts/build.mjs",
25
25
  "build:cli": "node scripts/build.mjs && bun build --compile --target=bun-linux-x64 --minify bin/switchroom.ts --outfile switchroom-linux-amd64",
26
26
  "pretest": "npm run build",
27
- "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-query-pending-permission.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/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
27
+ "test": "vitest run && bun test telegram-plugin/tests/history.test.ts telegram-plugin/tests/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.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/unhandled-rejection-policy.test.ts telegram-plugin/tests/registry-turns.test.ts telegram-plugin/registry/subagents.test.ts telegram-plugin/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/registry/api-registry.test.ts telegram-plugin/registry/turns-schema.test.ts telegram-plugin/tests/idle-footer-wiring.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
28
28
  "test:vitest": "vitest run",
29
- "test:bun": "bun test src/vault/grants.test.ts src/vault/grants-db.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/server-admin-only-keys.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/litellm/provision-apply-e2e.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/approval-origin.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/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-query-pending-permission.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/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
29
+ "test:bun": "bun test src/vault/grants.test.ts src/vault/grants-db.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/server-admin-only-keys.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/litellm/provision-apply-e2e.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/approval-origin.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/cross-turn-card-gate.test.ts telegram-plugin/tests/emission-authority-open-gate.test.ts telegram-plugin/tests/emission-authority-ping-gate.test.ts telegram-plugin/tests/emission-authority-card-drain-gate.test.ts telegram-plugin/tests/per-topic-current-turn.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/ipc-server-query-pending-permission.test.ts telegram-plugin/tests/ipc-server-check-pre-approved.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/registry/subagents-bugs.test.ts telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts telegram-plugin/tests/subagent-nested-dispatch.test.ts telegram-plugin/tests/nested-worker-visibility-harness.test.ts telegram-plugin/tests/turns-writer.test.ts telegram-plugin/tests/resume-inbound-builder.test.ts telegram-plugin/tests/subagent-tracker-hooks.test.ts telegram-plugin/tests/resolve-calling-subagent.test.ts telegram-plugin/tests/gateway-update-placeholder-dispatch.test.ts telegram-plugin/tests/status-query-telemetry.test.ts telegram-plugin/tests/reaction-trigger.test.ts telegram-plugin/tests/reaction-trigger-flow.test.ts telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts telegram-plugin/uat/load-env.test.ts telegram-plugin/uat/feed-matcher.test.ts telegram-plugin/uat/uat-driver.test.ts telegram-plugin/gateway/webhook-ingest-server.test.ts telegram-plugin/tests/skill-proposal-card.test.ts",
30
30
  "test:watch": "vitest",
31
31
  "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 && node scripts/check-stale-tool-descriptions.mjs && node scripts/check-web-subscription-honest.mjs",
32
32
  "lint:tsc": "tsc --noEmit",
@@ -43,7 +43,6 @@
43
43
  "@modelcontextprotocol/sdk": "^1.0.0",
44
44
  "@secretlint/core": "^12.2.0",
45
45
  "@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
46
- "@secretlint/types": "^12.2.0",
47
46
  "@xterm/headless": "^6.0.0",
48
47
  "bcryptjs": "^3.0.3",
49
48
  "chalk": "^5.4.0",
@@ -165,8 +165,70 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
165
165
  # The sidecar's own structured logging is written
166
166
  # directly to its log file; this wrapper only handles process
167
167
  # lifecycle. Ampersand-backgrounded by callers below.
168
+ # Size-based log rotation for a supervised sidecar's logfile (#3025).
169
+ # The gateway sidecar appends per-poll trace lines and can run for
170
+ # WEEKS without the supervise loop regaining control (it only loops on
171
+ # child exit), so rotate-at-restart alone let gateway-supervisor.log
172
+ # grow to 555MB on clerk / 162MB on klanker and fill the host disk.
173
+ # This background checker runs independently of the child's lifecycle.
174
+ #
175
+ # copytruncate (cp + `: >`), NOT `mv`: the supervised child holds an
176
+ # open fd on $_logfile (its stdout/stderr is redirected there with
177
+ # `>> "$_logfile"`). A plain `mv` would leave the child writing to the
178
+ # now-renamed inode, so the live log would silently stop growing and
179
+ # the rotated file would keep filling. copytruncate preserves the fd —
180
+ # the child keeps appending to the same (now-empty) inode. Worst-case
181
+ # race is a few log lines written between the cp and the truncate being
182
+ # lost; acceptable for a debug trace log. Keeps at most ~2×cap on disk
183
+ # (live + one .1 generation). Cap + interval are env-overridable.
184
+ _switchroom_log_rotator() {
185
+ local _logfile="$1"
186
+ local _max="${SWITCHROOM_SIDECAR_LOG_MAX_BYTES:-52428800}" # 50 MiB default
187
+ local _interval="${SWITCHROOM_SIDECAR_LOG_ROTATE_INTERVAL_SEC:-300}" # 5 min
188
+ # Validate BOTH env overrides up front; garbage falls back to the
189
+ # default. Unvalidated, a non-numeric interval makes `sleep` fail
190
+ # instantly and the `while true` loop hot-spins a CPU core for the
191
+ # container lifetime; a non-numeric cap makes the -gt test below
192
+ # error (a shell error line to stdout) every cycle forever. A zero
193
+ # interval would also hot-spin, so it falls back too.
194
+ case "$_max" in ''|*[!0-9]*) _max=52428800;; esac
195
+ case "$_interval" in ''|0|*[!0-9]*) _interval=300;; esac
196
+ # A cap of 0 disables rotation entirely (operator escape hatch).
197
+ # (Negative values contain '-' → non-numeric per the guard above →
198
+ # default cap, i.e. rotation stays on.)
199
+ [ "$_max" -eq 0 ] && return 0
200
+ while true; do
201
+ sleep "$_interval"
202
+ [ -f "$_logfile" ] || continue
203
+ local _size
204
+ _size=$(wc -c < "$_logfile" 2>/dev/null || echo 0)
205
+ # `wc -c < file` yields a bare integer; guard against empty/non-numeric.
206
+ case "$_size" in ''|*[!0-9]*) continue;; esac
207
+ if [ "$_size" -gt "$_max" ]; then
208
+ if cp "$_logfile" "$_logfile.1" 2>/dev/null; then
209
+ : > "$_logfile"
210
+ echo "[supervise] rotated $_logfile (${_size} bytes > ${_max} cap) → ${_logfile}.1" >> "$_logfile"
211
+ else
212
+ # cp failed (e.g. ENOSPC — exactly when rotation matters most).
213
+ # Fall back to truncate-without-backup: dropping the .1 backup
214
+ # beats a rotator that can never free space (#3025 review).
215
+ : > "$_logfile"
216
+ echo "[supervise] rotated $_logfile (${_size} bytes > ${_max} cap) WITHOUT backup — cp to ${_logfile}.1 failed (disk full?)" >> "$_logfile"
217
+ fi
218
+ fi
219
+ done
220
+ }
221
+
168
222
  _switchroom_supervise() {
169
223
  local _name="$1"; local _logfile="$2"; shift 2
224
+ # Start the size-based rotator for this sidecar's log (#3025). One
225
+ # per supervised sidecar; lives for the container lifetime alongside
226
+ # the respawn loop below.
227
+ _switchroom_log_rotator "$_logfile" &
228
+ # Record the rotator's pid so the oneshot-ok return path below can
229
+ # reap it — otherwise every completed one-shot sidecar leaks a
230
+ # background sleep loop for the container lifetime (#3025 review).
231
+ local _rotator_pid=$!
170
232
  # Optional `--oneshot-ok` flag (must directly follow the logfile):
171
233
  # marks a sidecar that can LEGITIMATELY complete. For such a sidecar
172
234
  # a clean exit 0 means "job done" → stop supervising, no respawn.
@@ -211,6 +273,9 @@ x-litellm-tags: agent:$SWITCHROOM_AGENT_NAME,profile:${SWITCHROOM_AGENT_PROFILE:
211
273
  # below — a crash always self-heals.
212
274
  if [ $_oneshot_ok -eq 1 ] && [ $_exit -eq 0 ]; then
213
275
  echo "[supervise] $_name completed cleanly (exit 0) — one-shot-ok, not respawning." >> "$_logfile"
276
+ # The sidecar is done — its log stops growing, so reap the
277
+ # rotator instead of leaking its sleep loop forever (#3025).
278
+ kill "$_rotator_pid" 2>/dev/null || true
214
279
  return 0
215
280
  fi
216
281
  # A run that stayed up for at least the backoff cap means the
@@ -3,7 +3,9 @@
3
3
  Rendered automatically by `switchroom apply` / `agent reconcile` from
4
4
  profiles/_shared/vault-protocol.md.hbs. Edit there — hand-edits to
5
5
  this section will be overwritten on next reconcile. To add agent-
6
- specific guidance, put it in workspace/CLAUDE.custom.md (sidecar).
6
+ specific guidance, edit CLAUDE.md below the "Yours (preserved across
7
+ apply)" marker line — that section is preserved verbatim across every
8
+ apply.
7
9
  -->
8
10
 
9
11
  ## Vault & secrets
@@ -43,7 +43,7 @@ Claude Code's file-based auto-memory is disabled. Use **Hindsight** MCP tools:
43
43
  - `mcp__hindsight__recall` — search past memories. Auto-fires on every message.
44
44
  - `mcp__hindsight__retain` — store important facts. Auto-retains every turn (chunked, a small ~3-turn window each time), so it's prompt and cheap.
45
45
  - `mcp__hindsight__reflect` — synthesize across memories for complex queries.
46
- - `mcp__hindsight__create_mental_model` — maintain semantic summaries (e.g. "codebase architecture").
46
+ - `mcp__switchroom-telegram__mental_model_propose` — propose a mental model: a standing semantic summary refreshed over the bank (e.g. "codebase architecture"). Mental-model writes are operator-approved: this posts an approval card and persists on approval. Don't call `mcp__hindsight__create_mental_model`/`update_mental_model` directly (they're denied and redirected here).
47
47
 
48
48
  Save proactively: architecture decisions, codebase patterns, conventions, known issues, user preferences. Your session may be compacted at any time — only Hindsight memories survive.
49
49
 
@@ -45,14 +45,14 @@ Hindsight is a memory bank with semantic search, knowledge graph, entity resolut
45
45
  ### Mental Models
46
46
  A mental model is a pre-computed semantic summary backed by reflection over the bank — a way to maintain a standing answer to a recurring question, semantically populated and refreshed.
47
47
 
48
- - `mcp__hindsight__create_mental_model(name, source_query)` create one for a recurring synthesis you need. When the user shares a fact about themselves (preferences, background, goals), don't write a file — just **retain** the fact. You do NOT need to build or maintain a per-agent "user profile": who the user is lives in dedicated per-user profile banks that the operator curates out-of-band, and recall surfaces it automatically.
48
+ Creating, updating, or refreshing a mental model is **operator-approved** — don't call `create_mental_model` / `update_mental_model` / `refresh_mental_model` / `delete_mental_model` directly (a direct call is denied and redirected). Use `mcp__switchroom-telegram__mental_model_propose(name, source_query)` when you need a recurring synthesis: it posts an approval card and persists the model on approval. When the user shares a fact about themselves (preferences, background, goals), don't write a file and don't propose a model — just **retain** the fact. You do NOT need to build or maintain a per-agent "user profile": who the user is lives in dedicated per-user profile banks that the operator curates out-of-band, and recall surfaces it automatically.
49
49
 
50
50
  ### Directives (replaces feedback rules)
51
51
  Hard rules the agent must follow during reflect — guardrails that are always applied.
52
52
 
53
53
  - `mcp__hindsight__create_directive(text)` — e.g., `create_directive("Always prefer TypeScript over JavaScript for this user's projects")`. When the user gives you a correction or "always do X" rule, create a directive instead of writing a feedback `.md` file.
54
54
 
55
- (Inspection tools like `list_memories`, `list_mental_models`, `update_mental_model`, `refresh_mental_model`, `list_directives`, `delete_directive` are available under the `mcp__hindsight__*` namespace if you ever need them, but you rarely should — Hindsight's own auto-recall surfaces what matters and the operator handles bank curation out-of-band.)
55
+ (Read-only inspection tools like `list_memories`, `list_mental_models`, `get_mental_model`, `list_directives` are available under the `mcp__hindsight__*` namespace if you ever need them, but you rarely should — Hindsight's own auto-recall surfaces what matters and the operator handles bank curation out-of-band. The mental-model WRITE tools — `create`/`update`/`refresh`/`delete_mental_model` — are gated: propose via `mcp__switchroom-telegram__mental_model_propose`, don't call them directly.)
56
56
 
57
57
  ### What to retain — and what NOT to retain
58
58
 
@@ -41,7 +41,7 @@ Claude Code's file-based auto-memory is disabled. Use **Hindsight** MCP tools:
41
41
 
42
42
  - `mcp__hindsight__recall` — search past memories. Auto-fires every message.
43
43
  - `mcp__hindsight__retain` — store important facts. Auto-retains every turn (chunked, a small ~3-turn window each time), so it's prompt and cheap.
44
- - `mcp__hindsight__create_mental_model` — maintain models for "contacts", "active projects", "user preferences".
44
+ - `mcp__switchroom-telegram__mental_model_propose` — propose a mental model: a standing semantic summary refreshed over the bank (e.g. "contacts", "active projects", "user preferences"). Mental-model writes are operator-approved: this posts an approval card and persists on approval. Don't call `mcp__hindsight__create_mental_model`/`update_mental_model` directly (they're denied and redirected here).
45
45
 
46
46
  Save proactively: contacts and their roles, scheduling preferences, project status, decisions with rationale, communication templates. Only Hindsight memories survive compaction.
47
47
 
@@ -35,7 +35,7 @@ Claude Code's file-based auto-memory is disabled. Use **Hindsight** MCP tools:
35
35
 
36
36
  - `mcp__hindsight__recall` — search past memories. Auto-fires on every message.
37
37
  - `mcp__hindsight__retain` — store important facts. Auto-retains every turn (chunked, a small ~3-turn window each time), so it's prompt and cheap.
38
- - `mcp__hindsight__create_mental_model` — maintain a "fitness profile" mental model.
38
+ - `mcp__switchroom-telegram__mental_model_propose` — propose a mental model (e.g. a "fitness profile" a standing semantic summary refreshed over the bank). Mental-model writes are operator-approved: this posts an approval card and persists on approval. Don't call `mcp__hindsight__create_mental_model`/`update_mental_model` directly (they're denied and redirected here).
39
39
 
40
40
  Save proactively: workout logs, goals, PRs, preferences, patterns (e.g. "poor sleep on Sundays"), injuries/limitations. Only Hindsight memories survive session compaction.
41
41
 
@@ -32,6 +32,7 @@ import type { InboundMessage, PermissionEvent, StatusEvent } from '../gateway/ip
32
32
  import { matchesAllowRule } from '../permission-rule.js'
33
33
  import { createOutstandingPermissionLedger } from './permission-ledger.js'
34
34
  import { appendCrashBreadcrumb } from './crash-breadcrumb.js'
35
+ import { InboundDedup, shouldDedupInbound, dedupChatKey } from './inbound-dedup.js'
35
36
 
36
37
  installPluginLogger()
37
38
 
@@ -127,6 +128,7 @@ const TOOL_SCHEMAS = [
127
128
  callback_data: { type: 'string', description: 'Opaque tag delivered back to the agent on tap. Max 58 chars (gateway prepends an `agent:` prefix to the 64-byte Telegram limit). Mutually exclusive with url.' },
128
129
  ack_text: { type: 'string', description: 'Toast text shown to the user the instant they tap this button (#710). Default "✓ received". Max ~200 chars (Telegram answerCallbackQuery limit). Has no effect on URL buttons.' },
129
130
  single_use: { type: 'boolean', description: 'When true (default) tapping any single_use button on the message removes the entire keyboard so the user can\'t double-fire. Set false on buttons that should stay tappable (e.g. a "Refresh" button). If ANY button on the message has single_use:false the keyboard is preserved on tap.' },
131
+ inline_keyboard_confirm: { type: 'boolean', description: 'Per-message override for the "✅ You chose: <label> · HH:MM" body annotation on tap (#789). true forces the annotation even if the agent default is off; false skips it even if the agent default is on; omit to use the agent default. Only applies to single-use keyboards and requires the agent parseMode to be the default html. Has no effect on URL buttons. Note: annotation rebuilds the body from the message\'s plain text, so formatting entities on the original message are lost.' },
130
132
  },
131
133
  required: ['text'],
132
134
  },
@@ -563,6 +565,18 @@ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
563
565
  }
564
566
  }
565
567
 
568
+ // #2094 finding 4 — turn-complete reset. A successful reply / stream_reply
569
+ // is the bridge's turn-complete proxy: the agent delivered its answer for
570
+ // this chat, so the per-chat inbound-dedup set can be cleared (thread-
571
+ // agnostic — the reply may omit message_thread_id). Bounds memory and lets
572
+ // a genuinely NEW turn re-use ids the sweep might legitimately resend.
573
+ if (inboundDedupEnabled && (tool === 'reply' || tool === 'stream_reply')) {
574
+ const replyChatId = args.chat_id
575
+ if (typeof replyChatId === 'string' && replyChatId.length > 0) {
576
+ inboundDedup.clearChat(replyChatId)
577
+ }
578
+ }
579
+
566
580
  // The gateway returns the same shape as the legacy server.ts handlers:
567
581
  // { content: [{ type: 'text', text: '...' }] }
568
582
  if (result.result && typeof result.result === 'object' && 'content' in (result.result as object)) {
@@ -709,7 +723,30 @@ mcp.setNotificationHandler(
709
723
 
710
724
  let ipc: IpcClientHandle | null = null
711
725
 
726
+ // #2094 finding 4 — bridge-side messageId dedup. Guards against the
727
+ // stranded-message sweep re-delivering a genuine user message that was
728
+ // already forwarded to claude (double-executing side-effectful commands,
729
+ // double 👀). Cleared per-chat on turn-complete (a successful reply /
730
+ // stream_reply, below) and capped defensively. Kill switch:
731
+ // SWITCHROOM_INBOUND_DEDUP=0.
732
+ const inboundDedup = new InboundDedup()
733
+ const inboundDedupEnabled = process.env.SWITCHROOM_INBOUND_DEDUP !== '0'
734
+
712
735
  function onInbound(msg: InboundMessage): void {
736
+ // Drop a re-delivered duplicate of a plain user message before it reaches
737
+ // the claude session. Only genuine re-deliverable messages are eligible
738
+ // (see shouldDedupInbound) — synthetic / button / structured-event inbounds
739
+ // are always forwarded.
740
+ if (inboundDedupEnabled && shouldDedupInbound(msg)) {
741
+ const chatKey = dedupChatKey(msg.chatId, msg.threadId)
742
+ if (inboundDedup.checkAndRecord(chatKey, msg.messageId) === 'duplicate') {
743
+ process.stderr.write(
744
+ `telegram bridge: dropping duplicate inbound messageId=${msg.messageId} ` +
745
+ `chat=${chatKey} (already forwarded this turn — sweep re-delivery, #2094)\n`,
746
+ )
747
+ return
748
+ }
749
+ }
713
750
  // Convert IPC InboundMessage → MCP channel notification
714
751
  mcp.notification({
715
752
  method: 'notifications/claude/channel',
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Bridge-side inbound messageId dedup (#2094 finding 4).
3
+ *
4
+ * `onInbound` forwards every IPC inbound to the claude session with no dedup.
5
+ * A genuine user message that was delivered successfully but slow to ack can
6
+ * be RE-SENT by the offline/stranded-message sweep — and claude then processes
7
+ * it twice: a side-effectful slash command double-executes, a 👀 is painted
8
+ * twice, etc. #2093's idle-gating shrank the window but did not close it.
9
+ *
10
+ * Durable fix: a per-chat set of already-forwarded messageIds. A repeat of a
11
+ * messageId within the same turn is dropped. The set is cleared on
12
+ * turn-complete (the bridge's turn-complete proxy is a successful
13
+ * reply/stream_reply for that chat) so memory stays bounded, and it is capped
14
+ * defensively so a chat that never replies can't grow the set without limit.
15
+ *
16
+ * SCOPE — only PLAIN user messages are deduped (see `shouldDedupInbound`).
17
+ * Synthetic inbounds (meta.source: cron / resume / vault, messageId == a
18
+ * timestamp), button callbacks, and structured events (checklist task changes,
19
+ * reactions) are NEVER deduped: they either carry unique ids already or
20
+ * legitimately repeat the same messageId for genuinely distinct events.
21
+ */
22
+
23
+ /** Defensive cap on tracked ids per chat (FIFO-evicted). Bounds memory for a
24
+ * chat that produces many messages without ever hitting a turn-complete. */
25
+ export const INBOUND_DEDUP_MAX_PER_CHAT = 512
26
+
27
+ export interface DedupInboundLike {
28
+ messageId: number
29
+ meta: Record<string, string>
30
+ }
31
+
32
+ /**
33
+ * Only genuine, re-deliverable user messages are eligible for dedup. Excludes:
34
+ * - messageId <= 0 (no real Telegram id to key on),
35
+ * - meta.source set (synthetic: cron / resume / vault / secret_* — unique
36
+ * timestamp ids, and re-delivery is handled upstream),
37
+ * - meta.button_callback (a tap; repeats are the user's intent),
38
+ * - meta.kind (structured events like checklist_task_changed — the same
39
+ * checklist messageId legitimately repeats for distinct state changes),
40
+ * - meta.event (reaction-class inbounds).
41
+ */
42
+ export function shouldDedupInbound(msg: DedupInboundLike): boolean {
43
+ if (!Number.isFinite(msg.messageId) || msg.messageId <= 0) return false
44
+ const m = msg.meta ?? {}
45
+ if (m.source != null) return false
46
+ if (m.button_callback != null) return false
47
+ if (m.kind != null) return false
48
+ if (m.event != null) return false
49
+ return true
50
+ }
51
+
52
+ /**
53
+ * Per-chat forwarded-messageId tracker. Keyed by chatKey (`chatId:threadId`).
54
+ */
55
+ export class InboundDedup {
56
+ private readonly byChat = new Map<string, Set<number>>()
57
+
58
+ constructor(private readonly maxPerChat: number = INBOUND_DEDUP_MAX_PER_CHAT) {}
59
+
60
+ /**
61
+ * Record `messageId` as forwarded for `chatKey`. Returns 'fresh' the first
62
+ * time a messageId is seen (caller forwards it) and 'duplicate' on a repeat
63
+ * (caller drops it). FIFO-evicts the oldest id once the per-chat cap is hit.
64
+ */
65
+ checkAndRecord(chatKey: string, messageId: number): 'fresh' | 'duplicate' {
66
+ let set = this.byChat.get(chatKey)
67
+ if (set == null) {
68
+ set = new Set<number>()
69
+ this.byChat.set(chatKey, set)
70
+ }
71
+ if (set.has(messageId)) return 'duplicate'
72
+ if (set.size >= this.maxPerChat) {
73
+ // Set iteration order is insertion order — drop the oldest.
74
+ const oldest = set.values().next().value
75
+ if (oldest !== undefined) set.delete(oldest)
76
+ }
77
+ set.add(messageId)
78
+ return 'fresh'
79
+ }
80
+
81
+ /** Clear every tracked id for a chat (all threads) — the turn-complete
82
+ * reset. Thread-agnostic so a reply that omits message_thread_id still
83
+ * clears the inbound's thread-scoped key. */
84
+ clearChat(chatId: string): void {
85
+ const prefix = `${chatId}:`
86
+ for (const key of this.byChat.keys()) {
87
+ if (key === chatId || key.startsWith(prefix)) this.byChat.delete(key)
88
+ }
89
+ }
90
+
91
+ /** Test/introspection helper: number of tracked ids for a chatKey. */
92
+ size(chatKey: string): number {
93
+ return this.byChat.get(chatKey)?.size ?? 0
94
+ }
95
+ }
96
+
97
+ /** Canonical chat-key derivation (mirrors gateway/chat-key.ts): a null/0
98
+ * thread collapses to '_'. */
99
+ export function dedupChatKey(chatId: string, threadId?: number): string {
100
+ return `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
101
+ }
@@ -23035,6 +23035,33 @@ function getNestedObj(obj, key) {
23035
23035
  var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS = 5 * 60000;
23036
23036
  var cooldownMap = new Map;
23037
23037
 
23038
+ // text-voice-scrub.ts
23039
+ var NULL = "\x00";
23040
+ var FENCE_PH = `${NULL}VS_FENCE`;
23041
+ var INLINE_PH = `${NULL}VS_INLINE`;
23042
+ var HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
23043
+ var HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
23044
+ var URL_PH = `${NULL}VS_URL`;
23045
+
23046
+ // model-unavailable.ts
23047
+ var transientUpstreamSignals = [
23048
+ "not your usage limit",
23049
+ "not your account",
23050
+ "not your account's",
23051
+ "temporarily limiting requests",
23052
+ "temporarily rate",
23053
+ "server is temporarily",
23054
+ "would exceed your account\u2019s rate limit",
23055
+ "would exceed your account's rate limit"
23056
+ ];
23057
+ function isTransientUpstreamSignal(text) {
23058
+ if (typeof text !== "string" || text.length === 0)
23059
+ return false;
23060
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
23061
+ const lower = sample.toLowerCase();
23062
+ return transientUpstreamSignals.some((s) => lower.includes(s));
23063
+ }
23064
+
23038
23065
  // tool-label-sidecar.ts
23039
23066
  import { existsSync as existsSync2, readFileSync, statSync as statSync2 } from "node:fs";
23040
23067
  import { join as join2 } from "node:path";
@@ -23453,7 +23480,8 @@ function detectErrorInTranscriptLine(line) {
23453
23480
  const status = typeof obj.apiErrorStatus === "number" ? obj.apiErrorStatus : null;
23454
23481
  const errStr = typeof obj.error === "string" ? obj.error : "";
23455
23482
  const text = extractAssistantText(obj);
23456
- const kind2 = status === 429 ? "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
23483
+ const kind2 = status === 429 ? isTransientUpstreamSignal(`${text}
23484
+ ${errStr}`) ? "rate-limited" : "quota-exhausted" : classifyClaudeError({ type: errStr, status, message: text });
23457
23485
  return {
23458
23486
  kind: kind2,
23459
23487
  raw: obj,
@@ -23547,6 +23575,7 @@ function startSessionTail(config2) {
23547
23575
  const onOperatorEvent = config2.onOperatorEvent;
23548
23576
  log?.(`session-tail: projectsDir=${projectsDir}`);
23549
23577
  const sidecars = new Map;
23578
+ const createSidecar = config2.createSidecar ?? createToolLabelSidecar;
23550
23579
  const stateDirForSidecar = process.env.TELEGRAM_STATE_DIR ?? null;
23551
23580
  function sessionIdForFile(file) {
23552
23581
  if (!file)
@@ -23561,7 +23590,7 @@ function startSessionTail(config2) {
23561
23590
  if (existing)
23562
23591
  return existing;
23563
23592
  try {
23564
- const s = createToolLabelSidecar({ stateDir: stateDirForSidecar, sessionId });
23593
+ const s = createSidecar({ stateDir: stateDirForSidecar, sessionId });
23565
23594
  sidecars.set(sessionId, s);
23566
23595
  s.onLabel((toolUseId, label, toolName) => {
23567
23596
  rawOnEvent({ kind: "tool_label", toolUseId, label, toolName });
@@ -23572,6 +23601,17 @@ function startSessionTail(config2) {
23572
23601
  return null;
23573
23602
  }
23574
23603
  }
23604
+ function stopSidecar(sessionId) {
23605
+ if (!sessionId)
23606
+ return;
23607
+ const s = sidecars.get(sessionId);
23608
+ if (!s)
23609
+ return;
23610
+ try {
23611
+ s.stop();
23612
+ } catch {}
23613
+ sidecars.delete(sessionId);
23614
+ }
23575
23615
  function decorate(ev, sessionId) {
23576
23616
  if (!sessionId)
23577
23617
  return ev;
@@ -23664,6 +23704,11 @@ function startSessionTail(config2) {
23664
23704
  } catch {}
23665
23705
  watcher = null;
23666
23706
  }
23707
+ const rotatedAwaySid = sessionIdForFile(currentFile);
23708
+ const nextSid = sessionIdForFile(file);
23709
+ if (rotatedAwaySid != null && rotatedAwaySid !== nextSid) {
23710
+ stopSidecar(rotatedAwaySid);
23711
+ }
23667
23712
  currentFile = file;
23668
23713
  const prior = fileCursors.get(file);
23669
23714
  if (prior != null) {
@@ -23696,7 +23741,7 @@ function startSessionTail(config2) {
23696
23741
  const multiAgent = isMultiAgentEnabled();
23697
23742
  const CAP_TOOL_USE_THRESHOLD = 30;
23698
23743
  const subTails = new Map;
23699
- const IDLE_FSWATCH_TTL_MS = 5 * 60 * 1000;
23744
+ const IDLE_FSWATCH_TTL_MS = Math.max(1000, config2.subTailIdleReapMs ?? 5 * 60 * 1000);
23700
23745
  function readSub(t) {
23701
23746
  if (stopped)
23702
23747
  return;
@@ -23803,6 +23848,7 @@ function startSessionTail(config2) {
23803
23848
  } catch {}
23804
23849
  t.watcher = null;
23805
23850
  }
23851
+ stopSidecar(sessionIdForFile(t.file));
23806
23852
  subTails.delete(file);
23807
23853
  log?.(`session-tail: reaped idle sub ${t.agentId} (${file})`);
23808
23854
  }
@@ -24562,6 +24608,7 @@ var BROAD_ONLY_TOOLS = new Set([
24562
24608
  "WebSearch",
24563
24609
  "Task",
24564
24610
  "Agent",
24611
+ "Workflow",
24565
24612
  "TodoWrite",
24566
24613
  "ExitPlanMode"
24567
24614
  ]);
@@ -24697,6 +24744,60 @@ function appendCrashBreadcrumb(logPath, kind, err, now = new Date) {
24697
24744
  } catch {}
24698
24745
  }
24699
24746
 
24747
+ // bridge/inbound-dedup.ts
24748
+ var INBOUND_DEDUP_MAX_PER_CHAT = 512;
24749
+ function shouldDedupInbound(msg) {
24750
+ if (!Number.isFinite(msg.messageId) || msg.messageId <= 0)
24751
+ return false;
24752
+ const m = msg.meta ?? {};
24753
+ if (m.source != null)
24754
+ return false;
24755
+ if (m.button_callback != null)
24756
+ return false;
24757
+ if (m.kind != null)
24758
+ return false;
24759
+ if (m.event != null)
24760
+ return false;
24761
+ return true;
24762
+ }
24763
+
24764
+ class InboundDedup {
24765
+ maxPerChat;
24766
+ byChat = new Map;
24767
+ constructor(maxPerChat = INBOUND_DEDUP_MAX_PER_CHAT) {
24768
+ this.maxPerChat = maxPerChat;
24769
+ }
24770
+ checkAndRecord(chatKey, messageId) {
24771
+ let set2 = this.byChat.get(chatKey);
24772
+ if (set2 == null) {
24773
+ set2 = new Set;
24774
+ this.byChat.set(chatKey, set2);
24775
+ }
24776
+ if (set2.has(messageId))
24777
+ return "duplicate";
24778
+ if (set2.size >= this.maxPerChat) {
24779
+ const oldest = set2.values().next().value;
24780
+ if (oldest !== undefined)
24781
+ set2.delete(oldest);
24782
+ }
24783
+ set2.add(messageId);
24784
+ return "fresh";
24785
+ }
24786
+ clearChat(chatId) {
24787
+ const prefix = `${chatId}:`;
24788
+ for (const key of this.byChat.keys()) {
24789
+ if (key === chatId || key.startsWith(prefix))
24790
+ this.byChat.delete(key);
24791
+ }
24792
+ }
24793
+ size(chatKey) {
24794
+ return this.byChat.get(chatKey)?.size ?? 0;
24795
+ }
24796
+ }
24797
+ function dedupChatKey(chatId, threadId) {
24798
+ return `${chatId}:${threadId == null || threadId === 0 ? "_" : threadId}`;
24799
+ }
24800
+
24700
24801
  // bridge/bridge.ts
24701
24802
  installPluginLogger();
24702
24803
  var STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join4(homedir3(), ".claude", "channels", "telegram");
@@ -24764,7 +24865,8 @@ var TOOL_SCHEMAS = [
24764
24865
  url: { type: "string", description: "Open this URL when tapped. Mutually exclusive with callback_data." },
24765
24866
  callback_data: { type: "string", description: "Opaque tag delivered back to the agent on tap. Max 58 chars (gateway prepends an `agent:` prefix to the 64-byte Telegram limit). Mutually exclusive with url." },
24766
24867
  ack_text: { type: "string", description: 'Toast text shown to the user the instant they tap this button (#710). Default "\u2713 received". Max ~200 chars (Telegram answerCallbackQuery limit). Has no effect on URL buttons.' },
24767
- single_use: { type: "boolean", description: `When true (default) tapping any single_use button on the message removes the entire keyboard so the user can't double-fire. Set false on buttons that should stay tappable (e.g. a "Refresh" button). If ANY button on the message has single_use:false the keyboard is preserved on tap.` }
24868
+ single_use: { type: "boolean", description: `When true (default) tapping any single_use button on the message removes the entire keyboard so the user can't double-fire. Set false on buttons that should stay tappable (e.g. a "Refresh" button). If ANY button on the message has single_use:false the keyboard is preserved on tap.` },
24869
+ inline_keyboard_confirm: { type: "boolean", description: `Per-message override for the "\u2705 You chose: <label> \xB7 HH:MM" body annotation on tap (#789). true forces the annotation even if the agent default is off; false skips it even if the agent default is on; omit to use the agent default. Only applies to single-use keyboards and requires the agent parseMode to be the default html. Has no effect on URL buttons. Note: annotation rebuilds the body from the message's plain text, so formatting entities on the original message are lost.` }
24768
24870
  },
24769
24871
  required: ["text"]
24770
24872
  }
@@ -25175,6 +25277,12 @@ mcp.setRequestHandler(CallToolRequestSchema2, async (req) => {
25175
25277
  isError: true
25176
25278
  };
25177
25279
  }
25280
+ if (inboundDedupEnabled && (tool === "reply" || tool === "stream_reply")) {
25281
+ const replyChatId = args.chat_id;
25282
+ if (typeof replyChatId === "string" && replyChatId.length > 0) {
25283
+ inboundDedup.clearChat(replyChatId);
25284
+ }
25285
+ }
25178
25286
  if (result.result && typeof result.result === "object" && "content" in result.result) {
25179
25287
  return result.result;
25180
25288
  }
@@ -25259,7 +25367,17 @@ mcp.setNotificationHandler(exports_external.object({
25259
25367
  });
25260
25368
  });
25261
25369
  var ipc = null;
25370
+ var inboundDedup = new InboundDedup;
25371
+ var inboundDedupEnabled = process.env.SWITCHROOM_INBOUND_DEDUP !== "0";
25262
25372
  function onInbound(msg) {
25373
+ if (inboundDedupEnabled && shouldDedupInbound(msg)) {
25374
+ const chatKey = dedupChatKey(msg.chatId, msg.threadId);
25375
+ if (inboundDedup.checkAndRecord(chatKey, msg.messageId) === "duplicate") {
25376
+ process.stderr.write(`telegram bridge: dropping duplicate inbound messageId=${msg.messageId} ` + `chat=${chatKey} (already forwarded this turn \u2014 sweep re-delivery, #2094)
25377
+ `);
25378
+ return;
25379
+ }
25380
+ }
25263
25381
  mcp.notification({
25264
25382
  method: "notifications/claude/channel",
25265
25383
  params: {