switchroom 0.18.11 → 0.18.12
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.
- package/dist/agent-scheduler/index.js +29 -5
- package/dist/auth-broker/index.js +53 -13
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/notion-write-pretool.mjs +29 -5
- package/dist/cli/switchroom.js +2453 -1293
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +504 -100
- package/dist/vault/approvals/kernel-server.js +53 -13
- package/dist/vault/broker/server.js +162 -114
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +73 -1
- package/telegram-plugin/dist/gateway/gateway.js +3602 -1007
- package/telegram-plugin/dist/server.js +74 -2
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/gateway/approval-hold.ts +583 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +76 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1482 -165
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +209 -2
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/send-gate-degraded.test.ts +443 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +698 -0
- package/telegram-plugin/send-gate.ts +982 -0
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +102 -2
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +296 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +284 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
|
@@ -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,
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
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
|
-
(
|
|
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
|
-
- `
|
|
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
|
-
- `
|
|
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
|
+
}
|
|
@@ -24562,6 +24562,7 @@ var BROAD_ONLY_TOOLS = new Set([
|
|
|
24562
24562
|
"WebSearch",
|
|
24563
24563
|
"Task",
|
|
24564
24564
|
"Agent",
|
|
24565
|
+
"Workflow",
|
|
24565
24566
|
"TodoWrite",
|
|
24566
24567
|
"ExitPlanMode"
|
|
24567
24568
|
]);
|
|
@@ -24697,6 +24698,60 @@ function appendCrashBreadcrumb(logPath, kind, err, now = new Date) {
|
|
|
24697
24698
|
} catch {}
|
|
24698
24699
|
}
|
|
24699
24700
|
|
|
24701
|
+
// bridge/inbound-dedup.ts
|
|
24702
|
+
var INBOUND_DEDUP_MAX_PER_CHAT = 512;
|
|
24703
|
+
function shouldDedupInbound(msg) {
|
|
24704
|
+
if (!Number.isFinite(msg.messageId) || msg.messageId <= 0)
|
|
24705
|
+
return false;
|
|
24706
|
+
const m = msg.meta ?? {};
|
|
24707
|
+
if (m.source != null)
|
|
24708
|
+
return false;
|
|
24709
|
+
if (m.button_callback != null)
|
|
24710
|
+
return false;
|
|
24711
|
+
if (m.kind != null)
|
|
24712
|
+
return false;
|
|
24713
|
+
if (m.event != null)
|
|
24714
|
+
return false;
|
|
24715
|
+
return true;
|
|
24716
|
+
}
|
|
24717
|
+
|
|
24718
|
+
class InboundDedup {
|
|
24719
|
+
maxPerChat;
|
|
24720
|
+
byChat = new Map;
|
|
24721
|
+
constructor(maxPerChat = INBOUND_DEDUP_MAX_PER_CHAT) {
|
|
24722
|
+
this.maxPerChat = maxPerChat;
|
|
24723
|
+
}
|
|
24724
|
+
checkAndRecord(chatKey, messageId) {
|
|
24725
|
+
let set2 = this.byChat.get(chatKey);
|
|
24726
|
+
if (set2 == null) {
|
|
24727
|
+
set2 = new Set;
|
|
24728
|
+
this.byChat.set(chatKey, set2);
|
|
24729
|
+
}
|
|
24730
|
+
if (set2.has(messageId))
|
|
24731
|
+
return "duplicate";
|
|
24732
|
+
if (set2.size >= this.maxPerChat) {
|
|
24733
|
+
const oldest = set2.values().next().value;
|
|
24734
|
+
if (oldest !== undefined)
|
|
24735
|
+
set2.delete(oldest);
|
|
24736
|
+
}
|
|
24737
|
+
set2.add(messageId);
|
|
24738
|
+
return "fresh";
|
|
24739
|
+
}
|
|
24740
|
+
clearChat(chatId) {
|
|
24741
|
+
const prefix = `${chatId}:`;
|
|
24742
|
+
for (const key of this.byChat.keys()) {
|
|
24743
|
+
if (key === chatId || key.startsWith(prefix))
|
|
24744
|
+
this.byChat.delete(key);
|
|
24745
|
+
}
|
|
24746
|
+
}
|
|
24747
|
+
size(chatKey) {
|
|
24748
|
+
return this.byChat.get(chatKey)?.size ?? 0;
|
|
24749
|
+
}
|
|
24750
|
+
}
|
|
24751
|
+
function dedupChatKey(chatId, threadId) {
|
|
24752
|
+
return `${chatId}:${threadId == null || threadId === 0 ? "_" : threadId}`;
|
|
24753
|
+
}
|
|
24754
|
+
|
|
24700
24755
|
// bridge/bridge.ts
|
|
24701
24756
|
installPluginLogger();
|
|
24702
24757
|
var STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join4(homedir3(), ".claude", "channels", "telegram");
|
|
@@ -24764,7 +24819,8 @@ var TOOL_SCHEMAS = [
|
|
|
24764
24819
|
url: { type: "string", description: "Open this URL when tapped. Mutually exclusive with callback_data." },
|
|
24765
24820
|
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
24821
|
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.` }
|
|
24822
|
+
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.` },
|
|
24823
|
+
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
24824
|
},
|
|
24769
24825
|
required: ["text"]
|
|
24770
24826
|
}
|
|
@@ -25175,6 +25231,12 @@ mcp.setRequestHandler(CallToolRequestSchema2, async (req) => {
|
|
|
25175
25231
|
isError: true
|
|
25176
25232
|
};
|
|
25177
25233
|
}
|
|
25234
|
+
if (inboundDedupEnabled && (tool === "reply" || tool === "stream_reply")) {
|
|
25235
|
+
const replyChatId = args.chat_id;
|
|
25236
|
+
if (typeof replyChatId === "string" && replyChatId.length > 0) {
|
|
25237
|
+
inboundDedup.clearChat(replyChatId);
|
|
25238
|
+
}
|
|
25239
|
+
}
|
|
25178
25240
|
if (result.result && typeof result.result === "object" && "content" in result.result) {
|
|
25179
25241
|
return result.result;
|
|
25180
25242
|
}
|
|
@@ -25259,7 +25321,17 @@ mcp.setNotificationHandler(exports_external.object({
|
|
|
25259
25321
|
});
|
|
25260
25322
|
});
|
|
25261
25323
|
var ipc = null;
|
|
25324
|
+
var inboundDedup = new InboundDedup;
|
|
25325
|
+
var inboundDedupEnabled = process.env.SWITCHROOM_INBOUND_DEDUP !== "0";
|
|
25262
25326
|
function onInbound(msg) {
|
|
25327
|
+
if (inboundDedupEnabled && shouldDedupInbound(msg)) {
|
|
25328
|
+
const chatKey = dedupChatKey(msg.chatId, msg.threadId);
|
|
25329
|
+
if (inboundDedup.checkAndRecord(chatKey, msg.messageId) === "duplicate") {
|
|
25330
|
+
process.stderr.write(`telegram bridge: dropping duplicate inbound messageId=${msg.messageId} ` + `chat=${chatKey} (already forwarded this turn \u2014 sweep re-delivery, #2094)
|
|
25331
|
+
`);
|
|
25332
|
+
return;
|
|
25333
|
+
}
|
|
25334
|
+
}
|
|
25263
25335
|
mcp.notification({
|
|
25264
25336
|
method: "notifications/claude/channel",
|
|
25265
25337
|
params: {
|