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.
- package/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- 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 +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -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 +88 -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 +1695 -230
- 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/history.ts +51 -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 +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -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/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -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 +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- 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 +173 -18
- 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 +327 -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/callback-query-handlers.test.ts +65 -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/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -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/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -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/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -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/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -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/subagent-watcher-fd-leak.test.ts +275 -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/tests/worktree-watch-cwds.test.ts +215 -1
- 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/telegram-plugin/worktree-watch-cwds.ts +194 -5
- 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
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type AskUserArgs,
|
|
35
35
|
type AskUserOutcome,
|
|
36
36
|
} from '../ask-user.js'
|
|
37
|
+
import { redactAskUserFields, redactChecklistFields } from '../outbound-field-redact.js'
|
|
37
38
|
import { parseInterruptMarker } from '../interrupt-marker.js'
|
|
38
39
|
import {
|
|
39
40
|
ToolFlightTracker,
|
|
@@ -112,7 +113,7 @@ import {
|
|
|
112
113
|
import type { WebhookGatewayRecord } from '../../src/web/webhook-gateway-record.js'
|
|
113
114
|
import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
|
|
114
115
|
import type { PinState, DesiredPin } from '../status-pin.js'
|
|
115
|
-
import { decidePinAction } from '../status-pin.js'
|
|
116
|
+
import { decidePinAction, PinRightsCache } from '../status-pin.js'
|
|
116
117
|
import { formatTurnLifecycle, detectStatusSurfaceDegraded } from './status-surface-log.js'
|
|
117
118
|
import { parseSourceMessageId } from './source-message-id.js'
|
|
118
119
|
import {
|
|
@@ -155,6 +156,7 @@ import {
|
|
|
155
156
|
computeBootSweepStripTargets,
|
|
156
157
|
distinctRequestIds,
|
|
157
158
|
} from './permission-rearm.js'
|
|
159
|
+
import { sweepPermissionTtl } from './permission-ttl-sweep.js'
|
|
158
160
|
import { createMissedApprovalsStore, type MissedApproval } from './missed-approvals-store.js'
|
|
159
161
|
import {
|
|
160
162
|
createAlwaysAllowPersistQueue,
|
|
@@ -168,6 +170,15 @@ import {
|
|
|
168
170
|
buildMissedApprovalRetryInbound,
|
|
169
171
|
} from './missed-approvals-card.js'
|
|
170
172
|
import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
|
|
173
|
+
import {
|
|
174
|
+
createBlockedApprovalStore,
|
|
175
|
+
safeActionForRecord,
|
|
176
|
+
selectOldestHeld,
|
|
177
|
+
selectHeldForRedelivery,
|
|
178
|
+
holdReasonFor,
|
|
179
|
+
heldRetryBackoffMs,
|
|
180
|
+
type UndeliverableMark,
|
|
181
|
+
} from './approval-hold.js'
|
|
171
182
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
172
183
|
import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
|
|
173
184
|
import { formatModelLabel } from '../model-label.js'
|
|
@@ -177,6 +188,7 @@ import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
|
|
|
177
188
|
import { toolLabel } from '../tool-labels.js'
|
|
178
189
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
179
190
|
import { createTurnTypingLoop } from './turn-typing-loop.js'
|
|
191
|
+
import { createTypingEmitter, TYPING_REFRESH_MS } from '../typing-emitter.js'
|
|
180
192
|
import { type DraftStreamHandle } from '../draft-stream.js'
|
|
181
193
|
import { handlePtyPartialPure, type PtyHandlerState } from '../pty-partial-handler.js'
|
|
182
194
|
import { handleStreamReply } from '../stream-reply-handler.js'
|
|
@@ -186,10 +198,23 @@ import {
|
|
|
186
198
|
createSwallowingRetryApiCall,
|
|
187
199
|
retryWithThreadFallback,
|
|
188
200
|
isPhotoDimensionRejectError,
|
|
201
|
+
isFloodWaitActiveError,
|
|
189
202
|
} from '../retry-api-call.js'
|
|
203
|
+
import { createSendGate, sendGateEnabledFromEnv } from '../send-gate.js'
|
|
204
|
+
import { createStatsLogger, createFloodWindowObserver } from '../send-gate-observability.js'
|
|
190
205
|
import { classifyPhotoFile, rerouteResultSuffix } from '../photo-precheck.js'
|
|
191
206
|
import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
|
|
192
|
-
import {
|
|
207
|
+
import {
|
|
208
|
+
floodStatePath,
|
|
209
|
+
floodWindowsPath,
|
|
210
|
+
makeFloodWaitRecorder,
|
|
211
|
+
makeFloodWaitProbe,
|
|
212
|
+
makeFloodWindowRecorder,
|
|
213
|
+
loadInitialFloodWindows,
|
|
214
|
+
readFloodWindows,
|
|
215
|
+
markFloodWindowAlerted,
|
|
216
|
+
suppressNonEssentialSendMs,
|
|
217
|
+
} from '../flood-circuit-breaker.js'
|
|
193
218
|
import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
|
|
194
219
|
import { logStreamingEvent } from '../streaming-metrics.js'
|
|
195
220
|
import * as signalTracker from '../turn-signal-tracker.js'
|
|
@@ -253,6 +278,14 @@ import {
|
|
|
253
278
|
cancelAccountAuthSession,
|
|
254
279
|
cleanScratchDir as cleanAuthAddScratchDir,
|
|
255
280
|
} from './auth-add-flow.js'
|
|
281
|
+
import {
|
|
282
|
+
pendingLoopbackFlows,
|
|
283
|
+
startLoopbackFlow,
|
|
284
|
+
submitLoopbackRedirect,
|
|
285
|
+
cancelLoopbackFlow,
|
|
286
|
+
shouldConsumeLoopbackPaste,
|
|
287
|
+
trackFlowExit,
|
|
288
|
+
} from './auth-loopback-relay.js'
|
|
256
289
|
import {
|
|
257
290
|
initHistory, recordInbound, recordOutbound, recordEdit,
|
|
258
291
|
deleteFromHistory, query as queryHistory, getLatestInboundMessageId,
|
|
@@ -308,10 +341,13 @@ import {
|
|
|
308
341
|
} from '../telegram-button-constraints.js'
|
|
309
342
|
import {
|
|
310
343
|
wrapAgentCallbacks,
|
|
344
|
+
redactAgentKeyboard,
|
|
311
345
|
parseAgentCallback,
|
|
312
346
|
extractAgentButtonMeta,
|
|
313
347
|
keyboardIsSingleUse,
|
|
314
348
|
finalizeCallback,
|
|
349
|
+
resolveTapAnnotation,
|
|
350
|
+
applyTapAnnotationEdit,
|
|
315
351
|
type AgentButtonMeta,
|
|
316
352
|
} from '../inline-keyboard-callbacks.js'
|
|
317
353
|
import {
|
|
@@ -340,6 +376,8 @@ import {
|
|
|
340
376
|
decideTurnFlush,
|
|
341
377
|
isTurnFlushSafetyEnabled,
|
|
342
378
|
} from '../turn-flush-safety.js'
|
|
379
|
+
// #1667 — pure decision core for the turn_end answer-delivery gate (#1664).
|
|
380
|
+
import { decideTurnEndGate } from './turn-end-gate.js'
|
|
343
381
|
// #1122 PR3: turn-flush-prose-recovery removed with the progress card.
|
|
344
382
|
import { resolveAgentDirFromEnv } from '../agent-dir.js'
|
|
345
383
|
import {
|
|
@@ -408,6 +446,7 @@ import {
|
|
|
408
446
|
readSessionModelFileRaw,
|
|
409
447
|
restoreSessionModelFileRaw,
|
|
410
448
|
clearSessionModelFile,
|
|
449
|
+
clearSessionModelBootAttempts,
|
|
411
450
|
readConfiguredDefaultModel,
|
|
412
451
|
writeRelaunchModelIntent,
|
|
413
452
|
clearRelaunchModelIntent,
|
|
@@ -453,7 +492,7 @@ import { resolveOutboundTopic as resolveOutboundTopicHelper, topicForRecipient,
|
|
|
453
492
|
import { readTurnUsages } from '../../src/agents/perf.js'
|
|
454
493
|
import { buildContextOccupancy, writeContextOccupancySnapshot } from './context-occupancy.js'
|
|
455
494
|
import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
|
|
456
|
-
import { decideIdleClear, idleDurationToMs, DEFAULT_IDLE_CLEAR_MS } from './idle-clear.js'
|
|
495
|
+
import { decideIdleClear, classifyIdleEvent, idleDurationToMs, DEFAULT_IDLE_CLEAR_MS } from './idle-clear.js'
|
|
457
496
|
import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
|
|
458
497
|
import {
|
|
459
498
|
tryHostdDispatch,
|
|
@@ -463,12 +502,19 @@ import {
|
|
|
463
502
|
pollHostdStatus,
|
|
464
503
|
hostdGetStatusOnce,
|
|
465
504
|
warnLegacySpawnIfHostdDisabled,
|
|
505
|
+
withOperatorAttestation,
|
|
466
506
|
_resetHostdEnabledCache,
|
|
467
507
|
} from './hostd-dispatch.js'
|
|
468
508
|
import { formatUpdateStatusLine } from './update-status-line.js'
|
|
469
509
|
import type { HostdRequest, HostdResponse } from '../../src/host-control/protocol.js'
|
|
470
510
|
import type { AgentAudit } from '../welcome-text.js'
|
|
471
511
|
import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
|
|
512
|
+
import {
|
|
513
|
+
createDmPinSweeper,
|
|
514
|
+
collectDmChatIdsFromStores,
|
|
515
|
+
unexpiredStoreRepinIds,
|
|
516
|
+
type DmPinSweeper,
|
|
517
|
+
} from './dm-pin-sweep.js'
|
|
472
518
|
import { startWebhookIngestServer } from './webhook-ingest-server.js'
|
|
473
519
|
import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
|
|
474
520
|
|
|
@@ -495,6 +541,7 @@ import {
|
|
|
495
541
|
type TrackedStatusPin,
|
|
496
542
|
} from './status-pin-store.js'
|
|
497
543
|
import {
|
|
544
|
+
loadActivityCards,
|
|
498
545
|
writeActivityCardRecord,
|
|
499
546
|
clearActivityCardRecord,
|
|
500
547
|
runActivityCardBootReaper,
|
|
@@ -502,6 +549,13 @@ import {
|
|
|
502
549
|
restartOrphanCardFinalizeText,
|
|
503
550
|
type ActivityCardStoreFsSeam,
|
|
504
551
|
} from './activity-card-store.js'
|
|
552
|
+
import {
|
|
553
|
+
loadQueuedCards,
|
|
554
|
+
writeQueuedCardRecord,
|
|
555
|
+
clearQueuedCardRecord,
|
|
556
|
+
runQueuedCardBootReaper,
|
|
557
|
+
type QueuedCardStoreFsSeam,
|
|
558
|
+
} from './queued-card-store.js'
|
|
505
559
|
import {
|
|
506
560
|
decideWorkerPinReaps,
|
|
507
561
|
WORKER_PIN_TTL_MS_DEFAULT,
|
|
@@ -511,6 +565,7 @@ import { shouldSuppressRepresent } from './represent-guard.js'
|
|
|
511
565
|
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
512
566
|
import { createInboundSpool } from './inbound-spool.js'
|
|
513
567
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
568
|
+
import { withTurnEndGateBackstop } from './turn-end-gate-backstop.js'
|
|
514
569
|
import { decideInboundDelivery, reserveInboundDelivery } from './inbound-delivery-gate.js'
|
|
515
570
|
import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
|
|
516
571
|
import { decideFeedReopen } from './feed-reopen-gate.js'
|
|
@@ -589,6 +644,7 @@ import type {
|
|
|
589
644
|
SendOutboundMessage,
|
|
590
645
|
QuotaWallDetectedMessage,
|
|
591
646
|
QueryPendingPermissionMessage,
|
|
647
|
+
CheckPreApprovedMessage,
|
|
592
648
|
PostSkillProposalMessage,
|
|
593
649
|
PermissionEvent,
|
|
594
650
|
RolloutStatusPostMessage,
|
|
@@ -637,8 +693,8 @@ import {
|
|
|
637
693
|
startSubagentWatcher,
|
|
638
694
|
type SubagentWatcherHandle,
|
|
639
695
|
} from '../subagent-watcher.js'
|
|
640
|
-
import { listRecords as listWorktreeRecords } from '../../src/worktree/registry.js'
|
|
641
|
-
import {
|
|
696
|
+
import { listRecords as listWorktreeRecords, touchHeartbeat as touchWorktreeHeartbeat } from '../../src/worktree/registry.js'
|
|
697
|
+
import { makeWorktreeWatchProvider } from '../worktree-watch-cwds.js'
|
|
642
698
|
import {
|
|
643
699
|
startBootCard,
|
|
644
700
|
resolvePersonaName,
|
|
@@ -664,6 +720,7 @@ import {
|
|
|
664
720
|
import { createScopedGrantStore } from './scoped-grant-store.js'
|
|
665
721
|
import { grantRestartDecision, type GrantRestartDecision } from './grant-restart.js'
|
|
666
722
|
import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
|
|
723
|
+
import { isDiffPreApproved } from './pre-approval-check.js'
|
|
667
724
|
import {
|
|
668
725
|
readClaudeJsonOverage,
|
|
669
726
|
evaluateCreditState,
|
|
@@ -790,6 +847,69 @@ process.on('beforeExit', () => {
|
|
|
790
847
|
// ─── Env + state dir ──────────────────────────────────────────────────────
|
|
791
848
|
const STATE_DIR = process.env.TELEGRAM_STATE_DIR ?? join(homedir(), '.claude', 'channels', 'telegram')
|
|
792
849
|
const permCardStore = createPermissionCardStore(STATE_DIR)
|
|
850
|
+
// #3084 follow-up — the blocked-approval surface. A SHARED top-level directory
|
|
851
|
+
// (compose binds host `~/.switchroom/blocked-approvals` here), not per-agent
|
|
852
|
+
// state: switchroom-web reads every agent's record from one place, and the
|
|
853
|
+
// records are 0644 so its uid-1000 process can actually read them. The agent's
|
|
854
|
+
// own telegram state is 0600 and is NOT web-readable.
|
|
855
|
+
// If the mount is missing (a container predating the volume), the write lands
|
|
856
|
+
// inside the container and the hold still works — only the off-Telegram
|
|
857
|
+
// SURFACE is lost. The record is best-effort by construction; it must never be
|
|
858
|
+
// able to fail the hold back into an auto-deny.
|
|
859
|
+
const BLOCKED_APPROVALS_DIR =
|
|
860
|
+
process.env.SWITCHROOM_BLOCKED_APPROVALS_DIR ?? '/state/blocked-approvals'
|
|
861
|
+
// The canonical "which agent am I" identity (profiles/_base/start.sh.hbs:430).
|
|
862
|
+
const AGENT_NAME = process.env.SWITCHROOM_AGENT_NAME ?? 'agent'
|
|
863
|
+
const blockedApprovalStore = createBlockedApprovalStore(
|
|
864
|
+
BLOCKED_APPROVALS_DIR,
|
|
865
|
+
AGENT_NAME,
|
|
866
|
+
// Fallback: the agent's OWN state dir, which the scaffold chowns to the agent
|
|
867
|
+
// uid, so a write there always succeeds. Guarantees the record can never be
|
|
868
|
+
// silently lost when the shared dir isn't writable by this agent's uid.
|
|
869
|
+
process.env.SWITCHROOM_AGENT_STATE_DIR ?? '/state/agent',
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Rewrite the blocked-approval surface from the LIVE pending state.
|
|
874
|
+
*
|
|
875
|
+
* A reconcile, not a delete — deliberately. The store holds one record per
|
|
876
|
+
* agent, but an agent can hold SEVERAL permissions at once: Claude Code issues
|
|
877
|
+
* parallel tool calls, each hitting `canUseTool`, and
|
|
878
|
+
* `cancelPendingPermissionsForHalt` iterates multiple pendings. A naive
|
|
879
|
+
* `clear()` on any single resolution would delete the file while ANOTHER
|
|
880
|
+
* request was still held, blinding the operator to a real, live block.
|
|
881
|
+
*
|
|
882
|
+
* So derive the record from whatever is still held. Oldest block wins — it has
|
|
883
|
+
* been waiting longest. Nothing held → clear.
|
|
884
|
+
*
|
|
885
|
+
* Also called at BOOT. `pendingPermissions` is in-memory and empty on a fresh
|
|
886
|
+
* process, so this clears any record orphaned by a restart mid-hold — otherwise
|
|
887
|
+
* the dashboard would show a permanently blocked agent forever. Nothing can
|
|
888
|
+
* legitimately be held across a restart: the bridge re-sends unresolved requests
|
|
889
|
+
* on IPC reconnect (`bridge/permission-ledger.ts`), which re-raises them and
|
|
890
|
+
* re-marks them if the channel is still shut.
|
|
891
|
+
*/
|
|
892
|
+
function reconcileBlockedApprovals(): void {
|
|
893
|
+
// Selection (oldest hold wins; null ⇒ nothing held) is the pure, unit-tested
|
|
894
|
+
// `selectOldestHeld` in approval-hold.ts — keep policy out of gateway.ts.
|
|
895
|
+
const oldest = selectOldestHeld(pendingPermissions)
|
|
896
|
+
if (oldest == null) {
|
|
897
|
+
blockedApprovalStore.clear()
|
|
898
|
+
return
|
|
899
|
+
}
|
|
900
|
+
blockedApprovalStore.write({
|
|
901
|
+
agent: AGENT_NAME,
|
|
902
|
+
requestId: oldest.requestId,
|
|
903
|
+
toolName: oldest.pend.tool_name,
|
|
904
|
+
// NOT bare naturalAction(): for Bash/Glob/Grep/WebFetch/mcp__* it
|
|
905
|
+
// interpolates the RAW tool input, and this file is world-readable.
|
|
906
|
+
action: safeActionForRecord(naturalAction, oldest.pend.tool_name, oldest.pend.input_preview),
|
|
907
|
+
blockedSince: oldest.pend.startedAt,
|
|
908
|
+
undeliverableSince: oldest.mark.since,
|
|
909
|
+
retryableAt: oldest.mark.retryableAt,
|
|
910
|
+
reason: oldest.mark.reason,
|
|
911
|
+
})
|
|
912
|
+
}
|
|
793
913
|
// Durable store for the four AGENT-INITIATED approval-card families
|
|
794
914
|
// (vault_request_access / vault_request_save / request_secret /
|
|
795
915
|
// mental_model_propose). Persists card METADATA only — never any secret value
|
|
@@ -1347,6 +1467,17 @@ type Access = {
|
|
|
1347
1467
|
short_name?: string
|
|
1348
1468
|
author_name?: string
|
|
1349
1469
|
}
|
|
1470
|
+
/** Auto-confirm "✅ You chose: X" annotation on inline_keyboard taps
|
|
1471
|
+
* (#789). When enabled, tapping an agent-emitted single-use button
|
|
1472
|
+
* annotates the source message body so the chat surface is
|
|
1473
|
+
* self-documenting. Off by default; only honored when parseMode is the
|
|
1474
|
+
* default 'html'. Projected from
|
|
1475
|
+
* channels.telegram.button_choice_confirmation by scaffold. */
|
|
1476
|
+
button_choice_confirmation?: {
|
|
1477
|
+
enabled?: boolean
|
|
1478
|
+
format?: string
|
|
1479
|
+
timezone?: 'gateway' | 'utc'
|
|
1480
|
+
}
|
|
1350
1481
|
}
|
|
1351
1482
|
|
|
1352
1483
|
function defaultAccess(): Access {
|
|
@@ -1431,6 +1562,8 @@ function readAccessFile(): Access {
|
|
|
1431
1562
|
voice_in: parsed.voice_in,
|
|
1432
1563
|
voice_out: parsed.voice_out,
|
|
1433
1564
|
telegraph: parsed.telegraph,
|
|
1565
|
+
// #789: button-choice-confirmation config projected by scaffold.
|
|
1566
|
+
button_choice_confirmation: parsed.button_choice_confirmation,
|
|
1434
1567
|
}
|
|
1435
1568
|
} catch (err) {
|
|
1436
1569
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return defaultAccess()
|
|
@@ -2586,6 +2719,17 @@ function turnInFlightForGate(): boolean {
|
|
|
2586
2719
|
// Keeping the gate closed for as long as there is an outstanding approval
|
|
2587
2720
|
// card prevents that race. The gate reopens when the card is tapped or times
|
|
2588
2721
|
// out (pendingPermissions.delete in finalizeCallback / TTL sweep).
|
|
2722
|
+
//
|
|
2723
|
+
// #3084 follow-up — ONE case now has no timeout valve: an approval HELD
|
|
2724
|
+
// because its card could not be DELIVERED (a Telegram flood ban) never
|
|
2725
|
+
// expires, by design. The leash may not fabricate a verdict for an ask no
|
|
2726
|
+
// human ever saw, so the entry — and therefore this gate — persists until a
|
|
2727
|
+
// human answers. An agent can consequently buffer normal inbound for the
|
|
2728
|
+
// duration of a channel outage. That is the intended trade: a buffered
|
|
2729
|
+
// message is recoverable, a silently auto-denied approval is not. The escape
|
|
2730
|
+
// hatches are unaffected — /allow, /deny and /pending are grammy command
|
|
2731
|
+
// handlers and do not sit behind this gate — and the block is surfaced
|
|
2732
|
+
// off-Telegram in blocked-approvals/<agent>.json.
|
|
2589
2733
|
const hasPendingApproval = pendingPermissions.size > 0
|
|
2590
2734
|
if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0 || hasPendingApproval
|
|
2591
2735
|
// Machine is authoritative. Run the log-only drift canary (#2794): the
|
|
@@ -3659,6 +3803,9 @@ function postQueuedStatus(chatId: string, bufferedThread: number, inFlightThread
|
|
|
3659
3803
|
return
|
|
3660
3804
|
}
|
|
3661
3805
|
queuedStatusMsgIds.set(key, { chatId, threadId: bufferedThread, messageId })
|
|
3806
|
+
// #3002: write through to the durable store so a gateway restart before the
|
|
3807
|
+
// in-process reap can still delete this orphaned card on boot.
|
|
3808
|
+
persistQueuedCard(key, chatId, bufferedThread, messageId)
|
|
3662
3809
|
})()
|
|
3663
3810
|
}
|
|
3664
3811
|
|
|
@@ -3695,6 +3842,9 @@ function reapQueuedStatus(chatId: string, thread: number | undefined): void {
|
|
|
3695
3842
|
const entry = queuedStatusMsgIds.get(key)
|
|
3696
3843
|
if (entry == null) return
|
|
3697
3844
|
queuedStatusMsgIds.delete(key)
|
|
3845
|
+
// #3002: drop the durable row too, scoped to this exact message id (reap-race
|
|
3846
|
+
// guard) so a fresh live card under the same key keeps its own protection.
|
|
3847
|
+
clearQueuedCard(key, entry.messageId)
|
|
3698
3848
|
void swallowingApiCall(
|
|
3699
3849
|
() => bot.api.deleteMessage(chatId, entry.messageId),
|
|
3700
3850
|
{ chat_id: chatId, verb: 'queued-status.reap', ...(entry.threadId != null ? { threadId: entry.threadId } : {}) },
|
|
@@ -3807,6 +3957,8 @@ function postBusyAck(chatId: string, threadId: number | undefined, text: string)
|
|
|
3807
3957
|
return
|
|
3808
3958
|
}
|
|
3809
3959
|
queuedStatusMsgIds.set(key, { chatId, threadId: threadId ?? null, messageId })
|
|
3960
|
+
// #3002: write through to the durable store (see postQueuedStatus).
|
|
3961
|
+
persistQueuedCard(key, chatId, threadId ?? null, messageId)
|
|
3810
3962
|
})()
|
|
3811
3963
|
}
|
|
3812
3964
|
|
|
@@ -3981,6 +4133,7 @@ function cancelPendingPermissionsForHalt(origin: string): void {
|
|
|
3981
4133
|
void stripCancelledPermissionCards(details.card_text, details.cards)
|
|
3982
4134
|
pendingPermissions.delete(requestId)
|
|
3983
4135
|
permCardStore.remove(requestId)
|
|
4136
|
+
reconcileBlockedApprovals()
|
|
3984
4137
|
process.stderr.write(
|
|
3985
4138
|
`telegram gateway: halt-now cancelled pending permission origin=${origin} ` +
|
|
3986
4139
|
`request=${requestId} tool=${details.tool_name}\n`,
|
|
@@ -4805,10 +4958,18 @@ function maybeProactiveCompact(): void {
|
|
|
4805
4958
|
// ─── Idle auto-clear ──────────────────────────────────────────────────────
|
|
4806
4959
|
// Wall-clock idle → /clear (idle-clear.ts). Independent of proactive-compact
|
|
4807
4960
|
// (occupancy-driven at the turn-end gate): a fully-idle agent never ends a
|
|
4808
|
-
// turn, so this runs on its own interval.
|
|
4809
|
-
//
|
|
4810
|
-
//
|
|
4961
|
+
// turn, so this runs on its own interval. "Idle" means NOTHING HAS HAPPENED
|
|
4962
|
+
// since the last thing happened — inbound, cron fire, or ANY claude session
|
|
4963
|
+
// event (turn start, tool call, tool result, text, sub-agent event, turn end)
|
|
4964
|
+
// resets the timer via markIdleActivity(); a turn ending additionally stamps
|
|
4965
|
+
// markIdleTurnEnd(). Fires once per idle period; never mid-turn
|
|
4966
|
+
// (turnInFlightForGate, the same gate compaction uses).
|
|
4967
|
+
//
|
|
4968
|
+
// It is emphatically NOT "no turn has *started* recently" — that reading wiped
|
|
4969
|
+
// overlord's 3h of working context on 2026-07-11 for the crime of being busy.
|
|
4970
|
+
// See the idle-clear.ts header.
|
|
4811
4971
|
let lastIdleActivityAt = Date.now();
|
|
4972
|
+
let lastIdleTurnEndAt: number | null = null;
|
|
4812
4973
|
let idleAutoCleared = false;
|
|
4813
4974
|
let idleClearDispatching = false;
|
|
4814
4975
|
|
|
@@ -4818,6 +4979,15 @@ function markIdleActivity(): void {
|
|
|
4818
4979
|
idleAutoCleared = false;
|
|
4819
4980
|
}
|
|
4820
4981
|
|
|
4982
|
+
/**
|
|
4983
|
+
* Stamp "a turn just ended". The idle window is measured from
|
|
4984
|
+
* max(lastActivityAt, lastTurnEndedAt), so a turn that ran LONGER than the
|
|
4985
|
+
* window can't be cleared on the first tick after `turnInFlight` goes false.
|
|
4986
|
+
*/
|
|
4987
|
+
function markIdleTurnEnd(): void {
|
|
4988
|
+
lastIdleTurnEndAt = Date.now();
|
|
4989
|
+
}
|
|
4990
|
+
|
|
4821
4991
|
/** Idle window in ms: env override → per-agent config → 3h default. 0 disables. */
|
|
4822
4992
|
function resolveIdleClearMs(): number {
|
|
4823
4993
|
const env = process.env.SWITCHROOM_IDLE_CLEAR_MS;
|
|
@@ -4849,6 +5019,7 @@ function maybeIdleClear(): void {
|
|
|
4849
5019
|
const decision = decideIdleClear(
|
|
4850
5020
|
{
|
|
4851
5021
|
lastActivityAt: lastIdleActivityAt,
|
|
5022
|
+
lastTurnEndedAt: lastIdleTurnEndAt,
|
|
4852
5023
|
idleClearMs,
|
|
4853
5024
|
alreadyCleared: idleAutoCleared,
|
|
4854
5025
|
turnInFlight: turnInFlightForGate(),
|
|
@@ -5275,6 +5446,241 @@ const PHOTO_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp'])
|
|
|
5275
5446
|
// The length/newline text splitter moved to outbound-send-path.ts (#2996) as
|
|
5276
5447
|
// `chunkText`; the sole gateway caller now goes through `computeReplyChunks`.
|
|
5277
5448
|
|
|
5449
|
+
// ─── Robust API call wrapper ──────────────────────────────────────────────
|
|
5450
|
+
// Extracted to telegram-plugin/retry-api-call.ts so it's unit-testable in
|
|
5451
|
+
// isolation; the gateway just composes the pure policy with its own logger.
|
|
5452
|
+
// #2923: the shared flood-wait marker. Every observed 429 retry_after window
|
|
5453
|
+
// is persisted here via onFloodWait, and both boot-card callsites consult the
|
|
5454
|
+
// SAME file to suppress a restart card while a per-bot flood ban is open (so a
|
|
5455
|
+
// restart doesn't post into the window and extend the ban). Falls back to a
|
|
5456
|
+
// no-op recorder when TELEGRAM_STATE_DIR is unset (dev/one-shot contexts).
|
|
5457
|
+
// STATE_DIR always resolves (env or a ~/.claude fallback), so this is live.
|
|
5458
|
+
// Declared ABOVE the typing indicator because the typing sends now route
|
|
5459
|
+
// through the same policy (#3084) — see nonEssentialApiCall below.
|
|
5460
|
+
const FLOOD_STATE_PATH = floodStatePath(STATE_DIR)
|
|
5461
|
+
// #3084 PR 2/3 — sibling file for SCOPED flood windows (global / chat / group /
|
|
5462
|
+
// msg-edit). Kept separate from the single-object flood-wait.json so #3094's
|
|
5463
|
+
// makeFloodWaitProbe schema is untouched (part3-design §7).
|
|
5464
|
+
const FLOOD_WINDOWS_PATH = floodWindowsPath(STATE_DIR)
|
|
5465
|
+
const recordFloodWindow = makeFloodWindowRecorder(FLOOD_WINDOWS_PATH)
|
|
5466
|
+
|
|
5467
|
+
// #3084 PR 2/3 — deterministic outbound send gate (token buckets + per-message
|
|
5468
|
+
// edit floor + no-op-edit skip + PRIORITY SHEDDING + DEGRADED MODE). Wrapped
|
|
5469
|
+
// HERE at the robustApiCall layer so every Bot API call routed through the
|
|
5470
|
+
// standard retry policy also transits one scheduler (no call site can bypass
|
|
5471
|
+
// it). ON BY DEFAULT (escape hatch): only when SWITCHROOM_TELEGRAM_SEND_GATE is
|
|
5472
|
+
// explicitly set to 0/false/off/no is the gate a pure passthrough and the retry
|
|
5473
|
+
// policy behaves exactly as before. Composes with #3094's pre-call flood gate
|
|
5474
|
+
// and #3097's non-essential
|
|
5475
|
+
// drop policy — those decide whether a call happens at all; this paces the
|
|
5476
|
+
// calls that do.
|
|
5477
|
+
//
|
|
5478
|
+
// §7 restart-proof flood state: initialWindows are loaded from disk BEFORE the
|
|
5479
|
+
// gate (and thus before ANY outbound call, boot cards included), so a boot mid-
|
|
5480
|
+
// ban never resends into an open window. onWindowOpen write-throughs every
|
|
5481
|
+
// runtime-opened window to FLOOD_WINDOWS_PATH; bootRamp starts the global
|
|
5482
|
+
// bucket at half capacity for 10s to absorb the boot-card burst.
|
|
5483
|
+
const sendGate = createSendGate({
|
|
5484
|
+
enabled: sendGateEnabledFromEnv(),
|
|
5485
|
+
initialWindows: loadInitialFloodWindows(FLOOD_STATE_PATH, FLOOD_WINDOWS_PATH, Date.now()),
|
|
5486
|
+
bootRamp: {},
|
|
5487
|
+
onWindowOpen: (scopeKey, untilTs) => recordFloodWindow(scopeKey, untilTs),
|
|
5488
|
+
})
|
|
5489
|
+
|
|
5490
|
+
// Hoisted so the held-card re-delivery sweep (#3084 follow-up) asks the SAME
|
|
5491
|
+
// probe `robustApiCall` does, off the same on-disk window. The sweep's check and
|
|
5492
|
+
// the retry policy's own pre-call short-circuit are then two independent reads
|
|
5493
|
+
// of one source of truth, not two notions of "is the channel open".
|
|
5494
|
+
const probeFloodWaitRemainingMs = makeFloodWaitProbe(FLOOD_STATE_PATH)
|
|
5495
|
+
const rawRobustApiCall = createRetryApiCall({
|
|
5496
|
+
log: (line) => process.stderr.write(line),
|
|
5497
|
+
onFloodWait: (retryAfterSec) => {
|
|
5498
|
+
// #2923/#3094 — persist the single-object global window (probe reads this).
|
|
5499
|
+
makeFloodWaitRecorder(FLOOD_STATE_PATH)(retryAfterSec)
|
|
5500
|
+
// #3084 PR 2 — also open a GLOBAL send-gate window so cosmetic traffic sheds
|
|
5501
|
+
// for the ban's duration even on a SHORT (slept-and-retried) 429 that never
|
|
5502
|
+
// throws FLOOD_WAIT_ACTIVE. Scope-precise windows are opened by the gate's
|
|
5503
|
+
// own FLOOD_WAIT_ACTIVE catch (which has the call's opts).
|
|
5504
|
+
try {
|
|
5505
|
+
sendGate.openFloodWindow('global', Date.now() + Math.max(0, retryAfterSec) * 1000)
|
|
5506
|
+
} catch {
|
|
5507
|
+
/* best-effort — never let the window hook break the retry path */
|
|
5508
|
+
}
|
|
5509
|
+
},
|
|
5510
|
+
// #3094: while a LONG per-bot ban is open, don't issue the call at all.
|
|
5511
|
+
// retryApiCall no longer sleeps a multi-hour retry_after (it throws
|
|
5512
|
+
// FLOOD_WAIT_ACTIVE instead), and the card surfaces re-drive on a 5-6s
|
|
5513
|
+
// heartbeat — without this gate that turns the fix into an amplifier that
|
|
5514
|
+
// fires thousands of requests into the open window and extends the ban.
|
|
5515
|
+
floodWaitRemainingMs: probeFloodWaitRemainingMs,
|
|
5516
|
+
})
|
|
5517
|
+
|
|
5518
|
+
const robustApiCall = <T>(
|
|
5519
|
+
fn: () => Promise<T>,
|
|
5520
|
+
opts?: Parameters<typeof rawRobustApiCall<T>>[1],
|
|
5521
|
+
): Promise<T> => sendGate.gate(() => rawRobustApiCall(fn, opts), opts)
|
|
5522
|
+
|
|
5523
|
+
// Fire-and-forget wrapper for outbound surfaces that previously had
|
|
5524
|
+
// `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
|
|
5525
|
+
// (instead of crashing the gateway) on THREAD_NOT_FOUND, give-up, 403,
|
|
5526
|
+
// and any non-benign error — logs a one-liner so the failure isn't
|
|
5527
|
+
// completely silent. See #1075.
|
|
5528
|
+
const swallowingApiCall = createSwallowingRetryApiCall(
|
|
5529
|
+
robustApiCall,
|
|
5530
|
+
(line) => process.stderr.write(line),
|
|
5531
|
+
)
|
|
5532
|
+
|
|
5533
|
+
/**
|
|
5534
|
+
* The ONE seam every `setMessageReaction` in this gateway goes through (#3155).
|
|
5535
|
+
*
|
|
5536
|
+
* Reactions are cosmetic outbound traffic. Before this seam, ~13 raw
|
|
5537
|
+
* `bot.api.setMessageReaction(...)` / `lockedBot.api.setMessageReaction(...)`
|
|
5538
|
+
* call sites fired reactions OUTSIDE the send gate AND the flood circuit
|
|
5539
|
+
* breaker, so:
|
|
5540
|
+
*
|
|
5541
|
+
* (a) reaction spam was never PACED/SHED by the gate when it's on — a busy
|
|
5542
|
+
* turn's status-reaction churn added straight onto the per-bot flood
|
|
5543
|
+
* budget the REPLIES need; and
|
|
5544
|
+
* (b) a 429 from a reaction was INVISIBLE to the breaker — the flood window
|
|
5545
|
+
* is only learned through the retry module's `onFloodWait` hook, which a
|
|
5546
|
+
* raw `bot.api` call never reaches. Reactions were a residual flood
|
|
5547
|
+
* vector even with the gate ON.
|
|
5548
|
+
*
|
|
5549
|
+
* Routing through `robustApiCall` (= chat-lock → send-gate → retry/breaker,
|
|
5550
|
+
* the SAME path every other outbound call takes) with
|
|
5551
|
+
* `priorityClass: 'cosmetic'` fixes both: the gate paces/sheds reactions under
|
|
5552
|
+
* pressure, and a 429 runs `onFloodWait` so the window is recorded. Callers
|
|
5553
|
+
* keep their own fire-and-forget / `.catch(() => {})` / `await` semantics —
|
|
5554
|
+
* this returns the promise and does NOT swallow.
|
|
5555
|
+
*/
|
|
5556
|
+
const gatedSetMessageReaction = (
|
|
5557
|
+
chatId: string,
|
|
5558
|
+
messageId: number,
|
|
5559
|
+
reaction: ReactionTypeEmoji[],
|
|
5560
|
+
): Promise<unknown> =>
|
|
5561
|
+
robustApiCall(() => lockedBot.api.setMessageReaction(chatId, messageId, reaction), {
|
|
5562
|
+
chat_id: chatId,
|
|
5563
|
+
verb: 'set-message-reaction',
|
|
5564
|
+
priorityClass: 'cosmetic',
|
|
5565
|
+
})
|
|
5566
|
+
|
|
5567
|
+
/** React with a single emoji through the gate (cosmetic). Wraps {@link gatedSetMessageReaction}. */
|
|
5568
|
+
const sendReaction = (
|
|
5569
|
+
chatId: string,
|
|
5570
|
+
messageId: number,
|
|
5571
|
+
emoji: ReactionTypeEmoji['emoji'],
|
|
5572
|
+
): Promise<unknown> => gatedSetMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }])
|
|
5573
|
+
|
|
5574
|
+
/**
|
|
5575
|
+
* Bot-API surface handed to `redactAuthCodeMessage` (#488) so its 🔑 reaction
|
|
5576
|
+
* routes through the send gate + flood breaker like every other reaction
|
|
5577
|
+
* (#3155). The OAuth-code DELETE stays raw/best-effort exactly as before — its
|
|
5578
|
+
* own `.then(onOk, onErr)` logging (the "token may still be visible" breadcrumb)
|
|
5579
|
+
* must keep firing, which a swallowing wrapper would suppress.
|
|
5580
|
+
*/
|
|
5581
|
+
const redactAuthCodeApi = {
|
|
5582
|
+
deleteMessage: (chatId: string, messageId: number) =>
|
|
5583
|
+
// allow-raw-bot-api: auth-code redact delete stays raw/best-effort (behavior unchanged, #488); #3155 gates only the reaction.
|
|
5584
|
+
bot.api.deleteMessage(chatId, messageId),
|
|
5585
|
+
setMessageReaction: (
|
|
5586
|
+
chatId: string,
|
|
5587
|
+
messageId: number,
|
|
5588
|
+
reaction: Array<{ type: 'emoji'; emoji: string }>,
|
|
5589
|
+
) => gatedSetMessageReaction(chatId, messageId, reaction as ReactionTypeEmoji[]),
|
|
5590
|
+
}
|
|
5591
|
+
|
|
5592
|
+
/**
|
|
5593
|
+
* The wrapper for NON-ESSENTIAL sends that must NEVER retry (#3084).
|
|
5594
|
+
*
|
|
5595
|
+
* A typing indicator is disposable: if it fails, the correct answer is to drop
|
|
5596
|
+
* it, because a retry spends more of the per-bot flood budget the REPLIES need
|
|
5597
|
+
* — retrying is what feeds a ban. But it must not be SILENT either: the typing
|
|
5598
|
+
* loop was the single largest emitter (55% of outbound volume) and it called
|
|
5599
|
+
* `bot.api` raw with `.catch(() => {})`, so the #2923 flood circuit breaker —
|
|
5600
|
+
* which only learns about 429s through `createRetryApiCall`'s `onFloodWait`
|
|
5601
|
+
* hook — was blind to 429s from the biggest source of them.
|
|
5602
|
+
*
|
|
5603
|
+
* `maxRetries: 1` + a no-op sleep gives exactly one attempt at the API, whatever
|
|
5604
|
+
* the outcome: a 429 still runs `onFloodWait` (recording the window to the
|
|
5605
|
+
* breaker), and then the call ends — a SHORT ban falls out of the loop as
|
|
5606
|
+
* `max retries exceeded`, a LONG one throws #3094's `FLOOD_WAIT_ACTIVE`. Either
|
|
5607
|
+
* way the ping is dropped, never slept and never retried. That is the whole
|
|
5608
|
+
* point: a retried non-essential send is what feeds a ban.
|
|
5609
|
+
*
|
|
5610
|
+
* Composes cleanly on top of #3094 (`bbe14471`): that PR bounded the in-process
|
|
5611
|
+
* flood sleep and added a pre-call gate for LONG open windows. This wrapper
|
|
5612
|
+
* pre-dates neither mechanism nor fights them — it sits under the emitter's own
|
|
5613
|
+
* flood gate, which short-circuits earlier still (a typing ping during a ban
|
|
5614
|
+
* never even reaches the retry layer). Defense in depth, in that order.
|
|
5615
|
+
*/
|
|
5616
|
+
// No `log` hook: retryApiCall's flood line reads "waiting Ns", which would be a
|
|
5617
|
+
// lie here — we never wait, we drop. And the error the caller finally sees names
|
|
5618
|
+
// neither the code nor the retry_after (retryApiCall replaces the original on
|
|
5619
|
+
// give-up) — and retry_after is the single number an operator needs during a
|
|
5620
|
+
// ban. So the honest 429 line is written HERE, in the onFloodWait hook, where
|
|
5621
|
+
// the real value is still in hand.
|
|
5622
|
+
const recordTypingFloodWait = makeFloodWaitRecorder(FLOOD_STATE_PATH)
|
|
5623
|
+
const nonEssentialApiCall = createRetryApiCall({
|
|
5624
|
+
maxRetries: 1,
|
|
5625
|
+
sleep: async () => {},
|
|
5626
|
+
onFloodWait: (retryAfterSec) => {
|
|
5627
|
+
recordTypingFloodWait(retryAfterSec)
|
|
5628
|
+
process.stderr.write(
|
|
5629
|
+
`telegram gateway: 429 flood-wait on a NON-ESSENTIAL send (retry_after=${retryAfterSec}s) — ` +
|
|
5630
|
+
`recorded to the flood breaker; the send is DROPPED, not retried (#3084)\n`,
|
|
5631
|
+
)
|
|
5632
|
+
},
|
|
5633
|
+
})
|
|
5634
|
+
|
|
5635
|
+
// #3084 PR 3/3 — observability + operator flood alerts (part3-design §6).
|
|
5636
|
+
// A low-frequency, change-gated stats line + flood-window open/close snapshots
|
|
5637
|
+
// to the gateway-supervisor log, and ONE operator alert per prolonged flood
|
|
5638
|
+
// window. All feature-flagged: when the send gate is OFF, `stats().enabled` is
|
|
5639
|
+
// false and both `tick()`s are pure no-ops (the interval is not even scheduled).
|
|
5640
|
+
const SEND_GATE_OBSERVE_INTERVAL_MS = 15_000
|
|
5641
|
+
const sendGateStatsLogger = createStatsLogger({
|
|
5642
|
+
stats: () => sendGate.stats(),
|
|
5643
|
+
log: (line) => process.stderr.write(line),
|
|
5644
|
+
clock: { now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)) },
|
|
5645
|
+
})
|
|
5646
|
+
const floodWindowObserver = createFloodWindowObserver({
|
|
5647
|
+
clock: { now: () => Date.now(), sleep: (ms) => new Promise((r) => setTimeout(r, ms)) },
|
|
5648
|
+
log: (line) => process.stderr.write(line),
|
|
5649
|
+
stats: () => sendGate.stats(),
|
|
5650
|
+
readWindows: (now) => readFloodWindows(FLOOD_WINDOWS_PATH, now),
|
|
5651
|
+
markAlerted: (scopeKey, alertedAt) =>
|
|
5652
|
+
markFloodWindowAlerted(FLOOD_WINDOWS_PATH, scopeKey, alertedAt, Date.now()),
|
|
5653
|
+
operatorChatId: () => loadAccess().allowFrom[0], // resolved per-tick (allowFrom can change)
|
|
5654
|
+
sendAlert: async (text) => {
|
|
5655
|
+
const operator = loadAccess().allowFrom[0]
|
|
5656
|
+
if (operator === undefined) {
|
|
5657
|
+
process.stderr.write(
|
|
5658
|
+
`telegram gateway: send-gate flood alert not sent — no operator chat (allowFrom empty)\n`,
|
|
5659
|
+
)
|
|
5660
|
+
return
|
|
5661
|
+
}
|
|
5662
|
+
await robustApiCall(
|
|
5663
|
+
// allow-raw-bot-api: operator flood alert, routed through robustApiCall.
|
|
5664
|
+
() => bot.api.sendRichMessage(operator, richMessage(text), {}),
|
|
5665
|
+
{ chat_id: String(operator), verb: 'send-gate-flood-alert', priorityClass: 'critical' },
|
|
5666
|
+
)
|
|
5667
|
+
},
|
|
5668
|
+
})
|
|
5669
|
+
if (sendGateEnabledFromEnv()) {
|
|
5670
|
+
const observeTimer = setInterval(() => {
|
|
5671
|
+
try {
|
|
5672
|
+
sendGateStatsLogger.tick()
|
|
5673
|
+
} catch {
|
|
5674
|
+
/* observability must never crash the gateway */
|
|
5675
|
+
}
|
|
5676
|
+
void floodWindowObserver.tick().catch(() => {
|
|
5677
|
+
/* best-effort — a failed observer tick must not surface */
|
|
5678
|
+
})
|
|
5679
|
+
}, SEND_GATE_OBSERVE_INTERVAL_MS)
|
|
5680
|
+
// Don't keep the event loop alive for observability alone.
|
|
5681
|
+
observeTimer.unref?.()
|
|
5682
|
+
}
|
|
5683
|
+
|
|
5278
5684
|
// ─── Typing indicator ─────────────────────────────────────────────────────
|
|
5279
5685
|
// All four state maps re-keyed from `chat_id` to `chatKey(chat, thread)`
|
|
5280
5686
|
// in PR3 of the supergroup-mode rollout. In supergroup mode one agent
|
|
@@ -5311,33 +5717,94 @@ const CHAT_ACTION_WHITELIST = new Set([
|
|
|
5311
5717
|
] as const)
|
|
5312
5718
|
type ChatAction = typeof CHAT_ACTION_WHITELIST extends Set<infer T> ? T : never
|
|
5313
5719
|
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
)
|
|
5319
|
-
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5323
|
-
|
|
5720
|
+
/**
|
|
5721
|
+
* The ONE seam every chat action in this gateway goes through (#3084).
|
|
5722
|
+
*
|
|
5723
|
+
* Both typing loops (tool-use `typingIntervals` below and the turn-level
|
|
5724
|
+
* `turnTypingLoop`) and the one-shot inbound pings share this emitter, so the
|
|
5725
|
+
* per-chat-key floor holds ACROSS them: at most one `sendChatAction` per chat
|
|
5726
|
+
* key per ~4 s window, no matter how many times a loop is restarted. That is
|
|
5727
|
+
* what decouples the typing rate from the agent's TOOL-CALL rate — the
|
|
5728
|
+
* regression that earned a 4.6-hour flood ban. A cold start (no ping inside
|
|
5729
|
+
* the floor) still fires instantly, so "typing…" lands the moment a turn
|
|
5730
|
+
* begins; only redundant restarts are dropped — and a dropped tick arms a
|
|
5731
|
+
* coalesced catch-up, so the indicator never goes dark for longer than the
|
|
5732
|
+
* floor. While a flood window is open, typing — non-essential by definition —
|
|
5733
|
+
* is not emitted at all.
|
|
5734
|
+
*
|
|
5735
|
+
* (`sendChatAction` is outside `check-bot-api-wrapping`'s pattern by design —
|
|
5736
|
+
* chat actions take no `message_thread_id`, so they were never in the
|
|
5737
|
+
* THREAD_NOT_FOUND blast radius the guard polices. It's routed through the
|
|
5738
|
+
* retry module anyway, because that is where the flood breaker's `onFloodWait`
|
|
5739
|
+
* hook lives and typing is the largest 429 source there is.)
|
|
5740
|
+
*/
|
|
5741
|
+
const typingEmitter = createTypingEmitter({
|
|
5742
|
+
chatKey: (chat_id, thread_id) => chatKey(chat_id, thread_id) as string,
|
|
5743
|
+
isSuppressed: () => suppressNonEssentialSendMs(FLOOD_STATE_PATH, Date.now()) > 0,
|
|
5744
|
+
send: (chat_id, thread_id, action) => {
|
|
5745
|
+
const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
|
|
5746
|
+
void nonEssentialApiCall(
|
|
5747
|
+
() => bot.api.sendChatAction(chat_id, action as ChatAction, sendOpts),
|
|
5748
|
+
{ chat_id, verb: 'sendChatAction' },
|
|
5749
|
+
).then(
|
|
5324
5750
|
() => { typingBackoffMs = 0 },
|
|
5325
5751
|
(err) => {
|
|
5326
5752
|
const msg = err instanceof Error ? err.message : String(err)
|
|
5327
5753
|
if (msg.includes('401') || msg.includes('Unauthorized')) {
|
|
5754
|
+
const key = chatKey(chat_id, thread_id) as string
|
|
5328
5755
|
typingBackoffMs = Math.min(Math.max(typingBackoffMs * 2 || 1000, 1000), TYPING_BACKOFF_MAX)
|
|
5329
5756
|
stopTypingLoop(chat_id, thread_id)
|
|
5330
5757
|
const retry = setTimeout(() => {
|
|
5331
5758
|
typingRetryTimers.delete(key)
|
|
5332
|
-
startTypingLoop(chat_id, thread_id, action)
|
|
5759
|
+
startTypingLoop(chat_id, thread_id, action as ChatAction)
|
|
5333
5760
|
}, typingBackoffMs)
|
|
5334
5761
|
typingRetryTimers.set(key, retry)
|
|
5762
|
+
return
|
|
5335
5763
|
}
|
|
5764
|
+
// A flood-wait already logged its retry_after (and hit the breaker) in
|
|
5765
|
+
// onFloodWait above. What lands here is either retryApiCall's opaque
|
|
5766
|
+
// give-up error (short ban) or #3094's FLOOD_WAIT_ACTIVE marker (long
|
|
5767
|
+
// ban / pre-call gate) — neither names the retry_after, so don't
|
|
5768
|
+
// double-log a strictly less informative line. Both are DROPS: a
|
|
5769
|
+
// typing ping is never retried, and the marker is caught here (this is
|
|
5770
|
+
// an onRejected handler), so it can't surface as an unhandled rejection
|
|
5771
|
+
// out of a fire-and-forget ping.
|
|
5772
|
+
if (isFloodWaitActiveError(err) || msg.includes('max retries exceeded')) return
|
|
5773
|
+
// Everything else is DROPPED, never retried — but logged, so the
|
|
5774
|
+
// largest outbound emitter is no longer silent (#3084).
|
|
5775
|
+
process.stderr.write(
|
|
5776
|
+
`telegram gateway: sendChatAction dropped (non-essential, not retried): ${msg}\n`,
|
|
5777
|
+
)
|
|
5336
5778
|
},
|
|
5337
5779
|
)
|
|
5338
|
-
}
|
|
5780
|
+
},
|
|
5781
|
+
})
|
|
5782
|
+
|
|
5783
|
+
/** Fire one chat action for (chat, thread), subject to the shared floor. */
|
|
5784
|
+
function emitChatAction(
|
|
5785
|
+
chat_id: string,
|
|
5786
|
+
thread_id: number | null = null,
|
|
5787
|
+
action: ChatAction = 'typing',
|
|
5788
|
+
): void {
|
|
5789
|
+
typingEmitter.emit(chat_id, thread_id, action)
|
|
5790
|
+
}
|
|
5791
|
+
|
|
5792
|
+
function startTypingLoop(
|
|
5793
|
+
chat_id: string,
|
|
5794
|
+
thread_id: number | null = null,
|
|
5795
|
+
action: ChatAction = 'typing',
|
|
5796
|
+
): void {
|
|
5797
|
+
stopTypingLoop(chat_id, thread_id)
|
|
5798
|
+
const key = chatKey(chat_id, thread_id) as string
|
|
5799
|
+
// The immediate fire is FLOOR-GATED (it was not, and that is #3084): the
|
|
5800
|
+
// tool-use wrapper restarts this loop on every tool call, so an unguarded
|
|
5801
|
+
// immediate send made the ping rate equal the tool-call rate and the 4 s
|
|
5802
|
+
// interval decorative. The emitter drops the restart's ping when this key
|
|
5803
|
+
// already pinged inside the window, and lets it straight through on a cold
|
|
5804
|
+
// start — so a turn still lights up "typing…" instantly.
|
|
5805
|
+
const send = () => emitChatAction(chat_id, thread_id, action)
|
|
5339
5806
|
send()
|
|
5340
|
-
typingIntervals.set(key, setInterval(send,
|
|
5807
|
+
typingIntervals.set(key, setInterval(send, TYPING_REFRESH_MS))
|
|
5341
5808
|
}
|
|
5342
5809
|
|
|
5343
5810
|
function stopTypingLoop(chat_id: string, thread_id: number | null = null): void {
|
|
@@ -5357,14 +5824,19 @@ function stopTypingLoop(chat_id: string, thread_id: number | null = null): void
|
|
|
5357
5824
|
// kill it and the chat would go dark for the rest of the turn — the exact
|
|
5358
5825
|
// black-box gap this closes. The dedicated map (private to the factory) makes
|
|
5359
5826
|
// the turn loop structurally immune to those stops: only the canonical turn-end
|
|
5360
|
-
// stop clears it.
|
|
5361
|
-
//
|
|
5827
|
+
// stop clears it.
|
|
5828
|
+
//
|
|
5829
|
+
// The interval map stays separate — but the SENDS do not (#3084). This loop and
|
|
5830
|
+
// `typingIntervals` target the SAME chat key, and the old comment here claimed
|
|
5831
|
+
// the redundant pings were "harmless — same action, and sendChatAction is
|
|
5832
|
+
// cheap". They are not cheap: they spend the per-bot flood budget the replies
|
|
5833
|
+
// need, and two independently-restarting loops on one key is exactly how the
|
|
5834
|
+
// rate compounded. Both loops now emit through `typingEmitter`, so the
|
|
5835
|
+
// per-chat-key floor is SHARED and neither loop can out-shout the other.
|
|
5362
5836
|
const turnTypingLoop = createTurnTypingLoop({
|
|
5363
|
-
sendChatAction: (chat_id, thread_id) =>
|
|
5364
|
-
const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
|
|
5365
|
-
void bot.api.sendChatAction(chat_id, 'typing', sendOpts).catch(() => {})
|
|
5366
|
-
},
|
|
5837
|
+
sendChatAction: (chat_id, thread_id) => emitChatAction(chat_id, thread_id, 'typing'),
|
|
5367
5838
|
chatKey: (chat_id, thread_id) => chatKey(chat_id, thread_id) as string,
|
|
5839
|
+
refreshMs: TYPING_REFRESH_MS,
|
|
5368
5840
|
})
|
|
5369
5841
|
|
|
5370
5842
|
function startTurnTypingLoop(chat_id: string, thread_id: number | null = null): void {
|
|
@@ -5373,6 +5845,11 @@ function startTurnTypingLoop(chat_id: string, thread_id: number | null = null):
|
|
|
5373
5845
|
|
|
5374
5846
|
function stopTurnTypingLoop(chat_id: string, thread_id: number | null = null): void {
|
|
5375
5847
|
turnTypingLoop.stop(chat_id, thread_id)
|
|
5848
|
+
// Canonical turn-end: cancel any catch-up the floor armed, so a tick dropped
|
|
5849
|
+
// in the last seconds of the turn can't resurrect "typing…" after the reply
|
|
5850
|
+
// has landed. Deliberately NOT done in `stopTypingLoop` — the tool loop stops
|
|
5851
|
+
// on every tool result, which is exactly the churn the catch-up covers.
|
|
5852
|
+
typingEmitter.cancelPending(chat_id, thread_id)
|
|
5376
5853
|
}
|
|
5377
5854
|
|
|
5378
5855
|
const typingWrapper = createTypingWrapper({
|
|
@@ -5381,31 +5858,6 @@ const typingWrapper = createTypingWrapper({
|
|
|
5381
5858
|
isSurfaceTool: isTelegramSurfaceTool,
|
|
5382
5859
|
})
|
|
5383
5860
|
|
|
5384
|
-
// ─── Robust API call wrapper ──────────────────────────────────────────────
|
|
5385
|
-
// Extracted to telegram-plugin/retry-api-call.ts so it's unit-testable in
|
|
5386
|
-
// isolation; the gateway just composes the pure policy with its own logger.
|
|
5387
|
-
// #2923: the shared flood-wait marker. Every observed 429 retry_after window
|
|
5388
|
-
// is persisted here via onFloodWait, and both boot-card callsites consult the
|
|
5389
|
-
// SAME file to suppress a restart card while a per-bot flood ban is open (so a
|
|
5390
|
-
// restart doesn't post into the window and extend the ban). Falls back to a
|
|
5391
|
-
// no-op recorder when TELEGRAM_STATE_DIR is unset (dev/one-shot contexts).
|
|
5392
|
-
// STATE_DIR always resolves (env or a ~/.claude fallback), so this is live.
|
|
5393
|
-
const FLOOD_STATE_PATH = floodStatePath(STATE_DIR)
|
|
5394
|
-
const robustApiCall = createRetryApiCall({
|
|
5395
|
-
log: (line) => process.stderr.write(line),
|
|
5396
|
-
onFloodWait: makeFloodWaitRecorder(FLOOD_STATE_PATH),
|
|
5397
|
-
})
|
|
5398
|
-
|
|
5399
|
-
// Fire-and-forget wrapper for outbound surfaces that previously had
|
|
5400
|
-
// `.catch(() => {})` directly on `bot.api.*` calls. Resolves to undefined
|
|
5401
|
-
// (instead of crashing the gateway) on THREAD_NOT_FOUND, give-up, 403,
|
|
5402
|
-
// and any non-benign error — logs a one-liner so the failure isn't
|
|
5403
|
-
// completely silent. See #1075.
|
|
5404
|
-
const swallowingApiCall = createSwallowingRetryApiCall(
|
|
5405
|
-
robustApiCall,
|
|
5406
|
-
(line) => process.stderr.write(line),
|
|
5407
|
-
)
|
|
5408
|
-
|
|
5409
5861
|
/**
|
|
5410
5862
|
* Adapter factory for `startBootCard`'s `BotApiForBootCard` interface.
|
|
5411
5863
|
*
|
|
@@ -5440,7 +5892,8 @@ function wrapBootCardApi(
|
|
|
5440
5892
|
richMessage(text),
|
|
5441
5893
|
sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
|
|
5442
5894
|
),
|
|
5443
|
-
|
|
5895
|
+
// #3084 PR 2: boot/config card CREATION is USEFUL (queue with TTL).
|
|
5896
|
+
{ ...opts(cid), priorityClass: 'useful' },
|
|
5444
5897
|
)
|
|
5445
5898
|
return sent as { message_id: number }
|
|
5446
5899
|
},
|
|
@@ -5453,7 +5906,9 @@ function wrapBootCardApi(
|
|
|
5453
5906
|
richMessage(text),
|
|
5454
5907
|
editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
|
|
5455
5908
|
),
|
|
5456
|
-
|
|
5909
|
+
// A boot-card EDIT is COSMETIC — shed under pressure; pass
|
|
5910
|
+
// messageId/editPayload so the per-message floor + no-op skip engage.
|
|
5911
|
+
{ ...opts(cid), priorityClass: 'cosmetic', messageId: mid, editPayload: text },
|
|
5457
5912
|
) as Promise<unknown>,
|
|
5458
5913
|
// Strict edit for the boot-card edit-in-place probe: distinguishes
|
|
5459
5914
|
// "message gone" (→ 'gone', caller sends fresh) from a landed/identical
|
|
@@ -5505,7 +5960,8 @@ function wrapIssuesCardApi(
|
|
|
5505
5960
|
richMessage(text),
|
|
5506
5961
|
sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
|
|
5507
5962
|
),
|
|
5508
|
-
|
|
5963
|
+
// #3084 PR 2: issues-card creation is USEFUL.
|
|
5964
|
+
{ ...opts(cid), priorityClass: 'useful' },
|
|
5509
5965
|
)
|
|
5510
5966
|
return sent as { message_id: number }
|
|
5511
5967
|
},
|
|
@@ -5518,7 +5974,8 @@ function wrapIssuesCardApi(
|
|
|
5518
5974
|
richMessage(text),
|
|
5519
5975
|
editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
|
|
5520
5976
|
),
|
|
5521
|
-
|
|
5977
|
+
// An issues-card EDIT is COSMETIC.
|
|
5978
|
+
{ ...opts(cid), priorityClass: 'cosmetic', messageId: mid, editPayload: text },
|
|
5522
5979
|
) as Promise<unknown>,
|
|
5523
5980
|
deleteMessage: (cid, mid) =>
|
|
5524
5981
|
robustApiCall(() => lockedBot.api.deleteMessage(cid, mid), opts(cid)) as Promise<unknown>,
|
|
@@ -5552,7 +6009,11 @@ const STATUS_QUERY_RE = /^\s*status\??\s*$/i
|
|
|
5552
6009
|
|
|
5553
6010
|
// ─── Permission handling ──────────────────────────────────────────────────
|
|
5554
6011
|
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
|
|
5555
|
-
|
|
6012
|
+
// `undeliverable` (#3084 follow-up): set when the card send failed against a
|
|
6013
|
+
// known-open Telegram flood window. While it is set the entry is HELD — the TTL
|
|
6014
|
+
// sweep skips it (never auto-deny an ask no human ever saw) and the reaper
|
|
6015
|
+
// re-posts the card once the window closes. Cleared on successful delivery.
|
|
6016
|
+
const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number; card_text: string; cards: { chatId: string; messageId: number; threadId?: number | null }[]; undeliverable?: UndeliverableMark | null; redeliveryFailures?: number }>()
|
|
5556
6017
|
// PERMISSION_TTL_MS / ttlForTool / the timed-out card builder now live in
|
|
5557
6018
|
// ./permission-timeout.ts (pure + unit-testable). hostd gated verbs get a
|
|
5558
6019
|
// 30-min window; everything else keeps the 10-min default.
|
|
@@ -5770,6 +6231,23 @@ function sweepStaleMentalModelCorrelations(now = Date.now()): void {
|
|
|
5770
6231
|
pendingMentalModelCorrelations.sweep(now)
|
|
5771
6232
|
}
|
|
5772
6233
|
|
|
6234
|
+
// #2975 Stage 2 — read-only pre-approval predicate. hostd asks (over the
|
|
6235
|
+
// approval-gateway socket, via `check_pre_approved`) whether an EXACT
|
|
6236
|
+
// (agent, diff) pair is already operator-consented so it can skip the
|
|
6237
|
+
// config_propose_edit rate limit for that persist. The forge-resistant,
|
|
6238
|
+
// read-only matching lives in the pure `isDiffPreApproved` (pre-approval-
|
|
6239
|
+
// check.ts) so its contract — true only for a byte-exact registered pair,
|
|
6240
|
+
// NEVER a mutation — is unit-testable without importing this module. Here we
|
|
6241
|
+
// only bind it to the live correlation stores + helpers.
|
|
6242
|
+
function isDiffPreApprovedLive(agentName: string, unifiedDiff: string): boolean {
|
|
6243
|
+
return isDiffPreApproved(agentName, unifiedDiff, {
|
|
6244
|
+
alwaysAllow: pendingAlwaysAllowCorrelations,
|
|
6245
|
+
mentalModel: pendingMentalModelCorrelations,
|
|
6246
|
+
extractAddedAllowRule,
|
|
6247
|
+
mentalModelCorrelationKey,
|
|
6248
|
+
})
|
|
6249
|
+
}
|
|
6250
|
+
|
|
5773
6251
|
// Scoped-approval store: the 30-min window that backs the "✅ Allow" tap for
|
|
5774
6252
|
// narrow non-destructive scopes (not a separate button — it IS what Allow
|
|
5775
6253
|
// means for those). Operator-tapped, gateway-side ONLY (never pushed to the
|
|
@@ -6525,9 +7003,260 @@ function restorePendingApprovalCards(): number {
|
|
|
6525
7003
|
return restored
|
|
6526
7004
|
}
|
|
6527
7005
|
|
|
7006
|
+
/**
|
|
7007
|
+
* Post the Approve/Deny card for `requestId` to every permission-card target.
|
|
7008
|
+
*
|
|
7009
|
+
* Extracted from `onPermissionRequest` (#3084 follow-up) so it has TWO callers:
|
|
7010
|
+
* the initial delivery, and the reaper's held-card re-delivery once a flood
|
|
7011
|
+
* window closes. Both paths must behave identically — same routing, same
|
|
7012
|
+
* thread-fallback, same landed-card bookkeeping, same hold-on-flood-wait — so
|
|
7013
|
+
* there is exactly one implementation of "post this card".
|
|
7014
|
+
*
|
|
7015
|
+
* Everything it needs is rebuilt from the pending entry, so a re-delivery an
|
|
7016
|
+
* hour later renders the same card the operator would have seen at T+0.
|
|
7017
|
+
*/
|
|
7018
|
+
function postPermissionCard(
|
|
7019
|
+
requestId: string,
|
|
7020
|
+
pend: NonNullable<ReturnType<typeof pendingPermissions.get>>,
|
|
7021
|
+
): void {
|
|
7022
|
+
// Register the in-flight flag HERE, not at the call sites, so BOTH callers are
|
|
7023
|
+
// covered. The sweep used to add it itself, but the ipcServer initial-delivery
|
|
7024
|
+
// call site did not — so a reaper tick landing between window-close and a slow
|
|
7025
|
+
// in-flight INITIAL send settling could post a duplicate card. One registration
|
|
7026
|
+
// point, one release point (the `.finally` below / the sync-throw catch).
|
|
7027
|
+
heldCardsInFlight.add(requestId)
|
|
7028
|
+
|
|
7029
|
+
// One increment per ATTEMPT, not per failing target. `targets` is N-wide and
|
|
7030
|
+
// every failing target runs the same `.catch` — incrementing there ratcheted
|
|
7031
|
+
// the backoff ladder N× faster than the documented 1m/2m/4m…30m. Compute the
|
|
7032
|
+
// attempt number once, up front; every failing target stamps the SAME value.
|
|
7033
|
+
const attemptFailures = (pend.redeliveryFailures ?? 0) + 1
|
|
7034
|
+
|
|
7035
|
+
try {
|
|
7036
|
+
const text = pend.card_text
|
|
7037
|
+
const showAlways =
|
|
7038
|
+
resolveScopedAllowChoices(pend.tool_name, pend.input_preview) != null
|
|
7039
|
+
const keyboard = buildPermissionActionRow(requestId, showAlways)
|
|
7040
|
+
// Route the card to the SAME place the post-verdict resume message lands
|
|
7041
|
+
// (resolvePermissionCardTargets): the ORIGINATING chat+topic when there's an
|
|
7042
|
+
// active turn — so a supergroup agent's card appears IN the topic the
|
|
7043
|
+
// operator asked from (marko's "CRM (Brevo)"), not the operator DM — else the
|
|
7044
|
+
// configured operator DMs, thread-stripped. The old code iterated `allowFrom`
|
|
7045
|
+
// unconditionally, so a supergroup card could only ever reach operator DMs
|
|
7046
|
+
// (the topic chat id is never in `allowFrom`) (marko, 2026-06-03).
|
|
7047
|
+
const targets = resolvePermissionCardTargets()
|
|
7048
|
+
|
|
7049
|
+
// ONE settle across ALL targets, not one per target (reviewer F1).
|
|
7050
|
+
//
|
|
7051
|
+
// The in-flight flag (guard iii) exists so the reaper can't re-post a card whose
|
|
7052
|
+
// previous post hasn't settled. `resolvePermissionCardTargets()` returns N
|
|
7053
|
+
// targets — it ends in `allowFrom.map(...)` — and on the RE-delivery path N>1 is
|
|
7054
|
+
// the COMMON case: re-delivery after a multi-hour ban is by construction past the
|
|
7055
|
+
// 30-min origin-recovery window, so it falls through to the operator-DM fan-out.
|
|
7056
|
+
// Clearing the flag per-target released it the moment the FIRST target settled
|
|
7057
|
+
// while others were still in flight, and the next tick re-posted to all of them —
|
|
7058
|
+
// a double delivery. Settle once, when every target is done.
|
|
7059
|
+
const sends = targets.map(({ chatId, threadId }) => {
|
|
7060
|
+
// The rich-markdown path pairs with formatPermissionCardBody (#1790) so its
|
|
7061
|
+
// bold/italic render. retryWithThreadFallback: if the topic was
|
|
7062
|
+
// deleted/recreated (stale thread id → 400 "message thread not found"),
|
|
7063
|
+
// re-send thread-less into the main chat so the card still ARRIVES rather
|
|
7064
|
+
// than vanishing.
|
|
7065
|
+
// allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware send
|
|
7066
|
+
return retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
|
|
7067
|
+
robustApiCall,
|
|
7068
|
+
(tid) =>
|
|
7069
|
+
bot.api.sendRichMessage(chatId, richMessage(text), {
|
|
7070
|
+
reply_markup: keyboard,
|
|
7071
|
+
...(tid != null ? { message_thread_id: tid } : {}),
|
|
7072
|
+
}),
|
|
7073
|
+
{ threadId, chat_id: chatId, verb: 'permission_request' },
|
|
7074
|
+
).then(sent => {
|
|
7075
|
+
// Record the live card's (chat, message) so the reaper can strip its inline
|
|
7076
|
+
// keyboard — a stale Approve button left tappable dispatches a verdict for a
|
|
7077
|
+
// dead request_id (Bug 2). The entry may already be gone (operator tapped
|
|
7078
|
+
// before this resolved); guard the lookup.
|
|
7079
|
+
const live = pendingPermissions.get(requestId)
|
|
7080
|
+
if (live && sent && typeof sent.message_id === 'number') {
|
|
7081
|
+
// #2787: record where the card ACTUALLY LANDED so the confirm sweep scopes
|
|
7082
|
+
// its re-delivery suspension to the real chat/topic. `sent.message_thread_id`
|
|
7083
|
+
// reflects reality in all three cases: topic success → tid, main-chat /
|
|
7084
|
+
// fallback → undefined.
|
|
7085
|
+
const landedThreadId = sent.message_thread_id ?? undefined
|
|
7086
|
+
live.cards.push({ chatId, messageId: sent.message_id, threadId: landedThreadId })
|
|
7087
|
+
// The card LANDED — the operator can see and tap it, so the block is over.
|
|
7088
|
+
// Drop the hold mark and reconcile the off-Telegram surface. (PR 3 also
|
|
7089
|
+
// resets `startedAt` here: the TTL measures how long the operator had to
|
|
7090
|
+
// answer, and until this moment they had nothing to answer.)
|
|
7091
|
+
if (live.undeliverable != null) {
|
|
7092
|
+
live.undeliverable = null
|
|
7093
|
+
live.redeliveryFailures = 0
|
|
7094
|
+
// RESET THE TTL CLOCK. Load-bearing, not cosmetic. `startedAt` is when
|
|
7095
|
+
// the agent asked; the TTL measures how long the operator had to
|
|
7096
|
+
// answer. Until this instant they had NOTHING to answer — the card did
|
|
7097
|
+
// not exist in any chat. Without the reset, a card held through a 4.6h
|
|
7098
|
+
// ban lands already-expired against a 60-min TTL and the very next
|
|
7099
|
+
// reaper tick auto-denies it: we would have MOVED the silent denial,
|
|
7100
|
+
// not removed it.
|
|
7101
|
+
live.startedAt = Date.now()
|
|
7102
|
+
reconcileBlockedApprovals()
|
|
7103
|
+
process.stderr.write(
|
|
7104
|
+
`telegram gateway: permission-card RE-DELIVERED request=${requestId} ` +
|
|
7105
|
+
`tool=${live.tool_name} chat=${chatId} — the operator can answer now; ` +
|
|
7106
|
+
`TTL clock restarted (they had zero seconds while the channel was shut)\n`,
|
|
7107
|
+
)
|
|
7108
|
+
}
|
|
7109
|
+
permCardStore.add({
|
|
7110
|
+
requestId,
|
|
7111
|
+
chatId,
|
|
7112
|
+
messageId: sent.message_id,
|
|
7113
|
+
startedAt: live.startedAt,
|
|
7114
|
+
toolName: live.tool_name,
|
|
7115
|
+
cardText: live.card_text,
|
|
7116
|
+
})
|
|
7117
|
+
}
|
|
7118
|
+
}).catch(e => {
|
|
7119
|
+
process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
|
|
7120
|
+
const live = pendingPermissions.get(requestId)
|
|
7121
|
+
if (live == null) return // operator already resolved it — nothing to hold
|
|
7122
|
+
|
|
7123
|
+
// A card already landed on ANOTHER target, so the ask is NOT undeliverable —
|
|
7124
|
+
// the operator can see and tap it (reviewer F2). Marking it held here would be
|
|
7125
|
+
// a PERMANENT wedge: `selectHeldForRedelivery` skips entries that have cards,
|
|
7126
|
+
// so the mark could never be cleared, and PR 3's TTL freeze would then keep
|
|
7127
|
+
// the entry alive forever with the surface stuck on "blocked".
|
|
7128
|
+
if (live.cards.length > 0) return
|
|
7129
|
+
|
|
7130
|
+
// The card did NOT land anywhere. Before this the error was logged and
|
|
7131
|
+
// dropped: the entry sat with `cards: []`, the operator saw nothing, and 60
|
|
7132
|
+
// minutes later the TTL sweep AUTO-DENIED an approval no human had ever been
|
|
7133
|
+
// shown. Now we hold it — but only for a TRANSIENT cause. `holdReasonFor`
|
|
7134
|
+
// decides; a permanent 400 (a card that will never format) still falls through
|
|
7135
|
+
// to the TTL, which PR 3 makes safe by giving the no-card timeout a
|
|
7136
|
+
// missed-approvals fallback.
|
|
7137
|
+
const reason = holdReasonFor(e)
|
|
7138
|
+
if (reason == null) return
|
|
7139
|
+
|
|
7140
|
+
// Bound the RATE, never the RETRY. A held entry is re-selected every tick, so a
|
|
7141
|
+
// target that keeps failing would otherwise re-send every 60s forever — the
|
|
7142
|
+
// amplifier this series exists to avoid. Back off instead (1m, 2m, 4m … 30m).
|
|
7143
|
+
// We never STOP retrying: a terminal give-up would strand the card even after
|
|
7144
|
+
// the channel recovered, and because turnInFlightForGate() holds the inbound
|
|
7145
|
+
// gate while a permission is pending, that would silently buffer the operator's
|
|
7146
|
+
// messages forever. Escalate the surface, never the verdict — and never abandon
|
|
7147
|
+
// the card.
|
|
7148
|
+
const failures = attemptFailures
|
|
7149
|
+
live.redeliveryFailures = failures
|
|
7150
|
+
const now = Date.now()
|
|
7151
|
+
// A flood-wait carries Telegram's own window end; everything else backs off.
|
|
7152
|
+
const retryableAt = isFloodWaitActiveError(e) ? e.untilTs : now + heldRetryBackoffMs(failures)
|
|
7153
|
+
// `since` is set ONCE — it is when the block began, not when we last retried.
|
|
7154
|
+
live.undeliverable = { since: live.undeliverable?.since ?? now, retryableAt, reason }
|
|
7155
|
+
// Reconcile the shared surface SYNCHRONOUSLY, here in the failure handler — not
|
|
7156
|
+
// on the 60s reaper tick. The operator is locked out of Telegram; this record is
|
|
7157
|
+
// the only way they learn an agent is blocked, so it must be live in under a
|
|
7158
|
+
// second, not up to a minute.
|
|
7159
|
+
reconcileBlockedApprovals()
|
|
7160
|
+
process.stderr.write(
|
|
7161
|
+
`telegram gateway: permission-card HELD (undeliverable) request=${requestId} ` +
|
|
7162
|
+
`tool=${live.tool_name} reason=${reason} attempt=${failures} ` +
|
|
7163
|
+
`retryable_at=${new Date(retryableAt).toISOString()}` +
|
|
7164
|
+
` — approval is held, NOT denied; re-delivering when the channel returns ` +
|
|
7165
|
+
`(backoff ${Math.round(heldRetryBackoffMs(failures) / 60000)}m)\n`,
|
|
7166
|
+
)
|
|
7167
|
+
})
|
|
7168
|
+
})
|
|
7169
|
+
|
|
7170
|
+
// Settle ONCE, after every target. `allSettled` never rejects, so the flag is
|
|
7171
|
+
// always released — including when `targets` is empty (no active turn AND no
|
|
7172
|
+
// configured operator DM), in which case `sends` is [] and this resolves at once.
|
|
7173
|
+
void Promise.allSettled(sends).finally(() => {
|
|
7174
|
+
heldCardsInFlight.delete(requestId)
|
|
7175
|
+
})
|
|
7176
|
+
} catch (e) {
|
|
7177
|
+
// A SYNCHRONOUS throw (resolveScopedAllowChoices / buildPermissionActionRow /
|
|
7178
|
+
// resolvePermissionCardTargets) never reaches the `.finally` above — without
|
|
7179
|
+
// this catch the in-flight flag would leak and the request would never be
|
|
7180
|
+
// re-selected by the sweep. Release the flag, then rethrow so the caller
|
|
7181
|
+
// (the sweep's own try/catch, or the ipc handler) sees the failure.
|
|
7182
|
+
heldCardsInFlight.delete(requestId)
|
|
7183
|
+
throw e
|
|
7184
|
+
}
|
|
7185
|
+
}
|
|
7186
|
+
|
|
7187
|
+
/**
|
|
7188
|
+
* Request ids whose card post is mid-flight — INITIAL delivery or re-delivery.
|
|
7189
|
+
* Guard (iii) against double-delivery: the 60s reaper must not re-post a card
|
|
7190
|
+
* whose previous post hasn't settled yet (a slow send would otherwise be
|
|
7191
|
+
* re-issued every tick). Registered at the top of postPermissionCard (so both
|
|
7192
|
+
* callers are covered) and cleared in its `.finally` — or its sync-throw catch.
|
|
7193
|
+
*/
|
|
7194
|
+
const heldCardsInFlight = new Set<string>()
|
|
7195
|
+
|
|
7196
|
+
/**
|
|
7197
|
+
* Re-deliver held permission cards once the flood window closes (#3084
|
|
7198
|
+
* follow-up, PR 2).
|
|
7199
|
+
*
|
|
7200
|
+
* The whole point of holding rather than auto-denying is that the ask comes
|
|
7201
|
+
* BACK. This is the half that brings it back.
|
|
7202
|
+
*
|
|
7203
|
+
* `selectHeldForRedelivery` owns the guards (window closed, held, no landed
|
|
7204
|
+
* card, not in flight, per-tick cap). `robustApiCall`'s own pre-call probe is a
|
|
7205
|
+
* further, independent short-circuit downstream — if the window re-opens
|
|
7206
|
+
* between selection and send, the send refuses itself and the entry is simply
|
|
7207
|
+
* re-marked with the longer window.
|
|
7208
|
+
*
|
|
7209
|
+
* The per-tick cap is the ban-safety property: a backlog of held cards must not
|
|
7210
|
+
* BURST the instant the window closes, because that burst is the one realistic
|
|
7211
|
+
* way this design could re-earn the ban it exists to survive. Deferred ids are
|
|
7212
|
+
* LOGGED, never silently dropped — they go out on the next tick.
|
|
7213
|
+
*/
|
|
7214
|
+
function sweepHeldPermissionCards(): void {
|
|
7215
|
+
const remaining = probeFloodWaitRemainingMs()
|
|
7216
|
+
const { send, deferred } = selectHeldForRedelivery(pendingPermissions.entries(), {
|
|
7217
|
+
floodRemainingMs: remaining,
|
|
7218
|
+
inFlight: heldCardsInFlight,
|
|
7219
|
+
now: Date.now(),
|
|
7220
|
+
})
|
|
7221
|
+
if (send.length === 0) return
|
|
7222
|
+
|
|
7223
|
+
process.stderr.write(
|
|
7224
|
+
`telegram gateway: flood window closed — re-delivering ${send.length} held ` +
|
|
7225
|
+
`permission card(s)` +
|
|
7226
|
+
(deferred.length > 0
|
|
7227
|
+
? `; ${deferred.length} deferred to the next tick by the per-tick cap ` +
|
|
7228
|
+
`(${deferred.join(', ')}) — deferred, NOT dropped`
|
|
7229
|
+
: '') + '\n',
|
|
7230
|
+
)
|
|
7231
|
+
for (const requestId of send) {
|
|
7232
|
+
const pend = pendingPermissions.get(requestId)
|
|
7233
|
+
if (pend == null) continue
|
|
7234
|
+
try {
|
|
7235
|
+
// postPermissionCard registers the in-flight flag itself (so the ipc
|
|
7236
|
+
// initial-delivery caller is covered too) and releases it once every
|
|
7237
|
+
// target settles — or on a sync throw, in its own catch.
|
|
7238
|
+
postPermissionCard(requestId, pend)
|
|
7239
|
+
} catch (e) {
|
|
7240
|
+
// A synchronous throw must not escape the setInterval callback (it would
|
|
7241
|
+
// kill the whole reaper tick, TTL sweep included). postPermissionCard has
|
|
7242
|
+
// already released the flag; delete again defensively so the entry is
|
|
7243
|
+
// re-selectable on the next tick, and log rather than rethrow.
|
|
7244
|
+
heldCardsInFlight.delete(requestId)
|
|
7245
|
+
process.stderr.write(
|
|
7246
|
+
`telegram gateway: held-card re-delivery for ${requestId} threw ` +
|
|
7247
|
+
`synchronously: ${e} — flag released, will retry on the next tick\n`,
|
|
7248
|
+
)
|
|
7249
|
+
}
|
|
7250
|
+
}
|
|
7251
|
+
}
|
|
7252
|
+
|
|
6528
7253
|
// 60-second sweep drops anything past its documented TTL.
|
|
6529
7254
|
const pendingStateReaper = setInterval(() => {
|
|
6530
7255
|
const now = Date.now()
|
|
7256
|
+
// #3084 follow-up — bring held cards BACK the moment the channel reopens.
|
|
7257
|
+
// Runs before the TTL sweep below so a card that can be re-delivered on this
|
|
7258
|
+
// tick is re-delivered, not considered for expiry.
|
|
7259
|
+
sweepHeldPermissionCards()
|
|
6531
7260
|
// OAuth-code state grouped first (pinned by secret-detect-oauth-code.test.ts).
|
|
6532
7261
|
pendingReauthFlows.sweep(now)
|
|
6533
7262
|
for (const [k, v] of pendingAuthAddFlows) {
|
|
@@ -6539,6 +7268,21 @@ const pendingStateReaper = setInterval(() => {
|
|
|
6539
7268
|
for (const [k, v] of awaitingAuthCodeAt) {
|
|
6540
7269
|
if (now - v > AUTH_CODE_CONTEXT_TTL_MS) awaitingAuthCodeAt.delete(k)
|
|
6541
7270
|
}
|
|
7271
|
+
// Loopback OAuth relay flows (issue #2582) — same TTL. Kill the waiting
|
|
7272
|
+
// CLI child so an abandoned consent never lingers with a bound listener.
|
|
7273
|
+
// Placed AFTER the OAuth-code cluster above, which secret-detect-oauth-
|
|
7274
|
+
// code.test.ts pins as contiguous within the first 800 chars of the
|
|
7275
|
+
// reaper (same precedent as the Microsoft connect sweep below). A flow
|
|
7276
|
+
// with a submit in flight is skipped (PR #3100 review finding 3): a paste
|
|
7277
|
+
// near the TTL boundary must not have its child killed mid-registration —
|
|
7278
|
+
// the submit path owns cleanup once `submitting` is set.
|
|
7279
|
+
for (const [k, v] of pendingLoopbackFlows) {
|
|
7280
|
+
if (v.submitting) continue
|
|
7281
|
+
if (now - v.startedAt > REAUTH_INTERCEPT_TTL_MS) {
|
|
7282
|
+
cancelLoopbackFlow(v)
|
|
7283
|
+
pendingLoopbackFlows.delete(k)
|
|
7284
|
+
}
|
|
7285
|
+
}
|
|
6542
7286
|
// Microsoft connect flows self-expire at the device code's own expiry
|
|
6543
7287
|
// (~15 min) — sweep past that + grace so an abandoned card doesn't pin
|
|
6544
7288
|
// its key. Setting cancelled makes any still-running poll bail. Placed
|
|
@@ -6561,11 +7305,19 @@ const pendingStateReaper = setInterval(() => {
|
|
|
6561
7305
|
if (now >= v.expiresAt) pendingAuthRmFlows.delete(k)
|
|
6562
7306
|
}
|
|
6563
7307
|
pendingVaultOps.sweep(now)
|
|
6564
|
-
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
|
|
6568
|
-
|
|
7308
|
+
// THE LEASH. The sweep — the guard AND the auto-deny it gates — now lives in
|
|
7309
|
+
// permission-ttl-sweep.ts, and the outcome test's harness drives that exact
|
|
7310
|
+
// function. It used to be an inline loop here while the harness kept a PRIVATE
|
|
7311
|
+
// copy of the guard, so deleting the real check left every behavioural assertion
|
|
7312
|
+
// GREEN and only a source-text grep noticed. A test that cannot fail is not a
|
|
7313
|
+
// test, and this is the test for `no-self-escalation`. One implementation, two
|
|
7314
|
+
// callers: there is no longer a gateway-side line whose deletion restores the
|
|
7315
|
+
// auto-deny without removing the sweep entirely.
|
|
7316
|
+
sweepPermissionTtl({
|
|
7317
|
+
entries: pendingPermissions,
|
|
7318
|
+
now,
|
|
7319
|
+
ttlForTool,
|
|
7320
|
+
onExpire: (k, v, ttl) => {
|
|
6569
7321
|
// Don't just drop it: the claude turn is suspended INSIDE the MCP
|
|
6570
7322
|
// permission call waiting for a verdict. A silent delete left it
|
|
6571
7323
|
// wedged forever when the operator never tapped — permanent
|
|
@@ -6612,8 +7364,19 @@ const pendingStateReaper = setInterval(() => {
|
|
|
6612
7364
|
// returns. Anchor to the card's own origin surface (where the operator
|
|
6613
7365
|
// would have tapped), so the digest lands in the same topic — not a
|
|
6614
7366
|
// fanned-out DM. Skip if the card was never posted anywhere.
|
|
7367
|
+
// The `cards[0]` hole (#3084 follow-up): this used to skip silently when the
|
|
7368
|
+
// card had never landed (`cards: []` → `origin === undefined`). That is
|
|
7369
|
+
// exactly the undeliverable case — so the request was auto-denied AND erased
|
|
7370
|
+
// from the only record that would have brought it back. The safety net had a
|
|
7371
|
+
// hole shaped exactly like the accident.
|
|
7372
|
+
//
|
|
7373
|
+
// Held entries no longer reach this code at all (the TTL freeze above skips
|
|
7374
|
+
// them), so this is belt-and-braces: ANY future path that times out a card
|
|
7375
|
+
// which never landed — including a PERMANENT 400, which holdReasonFor()
|
|
7376
|
+
// deliberately does not hold — still lands in the digest. Fall back to where
|
|
7377
|
+
// the card WOULD have gone.
|
|
6615
7378
|
if (MISSED_APPROVAL_REOFFER_ENABLED) {
|
|
6616
|
-
const origin = v.cards[0]
|
|
7379
|
+
const origin = v.cards[0] ?? resolvePermissionCardTargets()[0]
|
|
6617
7380
|
if (origin != null) {
|
|
6618
7381
|
missedApprovalsStore.add({
|
|
6619
7382
|
requestId: k,
|
|
@@ -6632,8 +7395,9 @@ const pendingStateReaper = setInterval(() => {
|
|
|
6632
7395
|
)
|
|
6633
7396
|
pendingPermissions.delete(k)
|
|
6634
7397
|
permCardStore.remove(k)
|
|
6635
|
-
|
|
6636
|
-
|
|
7398
|
+
reconcileBlockedApprovals()
|
|
7399
|
+
},
|
|
7400
|
+
})
|
|
6637
7401
|
// Drop no-repeat suppression entries past the safety-cap window (the primary
|
|
6638
7402
|
// bound is the operator-activity reset; this just keeps the map from growing).
|
|
6639
7403
|
for (const [sig, at] of permissionTimeoutSignatures) {
|
|
@@ -7194,6 +7958,12 @@ const statusPinChatIds = new Map<string, string>()
|
|
|
7194
7958
|
// re-pin of the same key keeps the original timestamp). Feeds the TTL gate of
|
|
7195
7959
|
// the mid-session `wk:` pin reaper (#3001); cleared alongside the state.
|
|
7196
7960
|
const statusPinPinnedAt = new Map<string, number>()
|
|
7961
|
+
// Rights-aware negative cache (#3024): chats where an auto status-pin attempt
|
|
7962
|
+
// failed with the permanent "not enough rights to manage pinned messages" 400.
|
|
7963
|
+
// Per-process only — a restart clears it so a later-granted pin right re-enables
|
|
7964
|
+
// auto-pin. The explicit `pin_message` MCP tool deliberately does NOT consult
|
|
7965
|
+
// this cache (it always attempts and surfaces the error to the agent).
|
|
7966
|
+
const statusPinRightsCache = new PinRightsCache()
|
|
7197
7967
|
|
|
7198
7968
|
// Durable snapshot of the pin claim set on the persistent per-agent volume
|
|
7199
7969
|
// (STATE_DIR = /state/agent/telegram in prod). Closes the crash hole: the
|
|
@@ -7228,6 +7998,48 @@ const activityCardStoreFs: ActivityCardStoreFsSeam = {
|
|
|
7228
7998
|
}
|
|
7229
7999
|
const activityCardPersistEnabled = !STATIC
|
|
7230
8000
|
|
|
8001
|
+
// Durable handle for the component-5 queued-status placeholder + #2995 busy-ack
|
|
8002
|
+
// card (#3002). Both track their sent message id only in the in-memory
|
|
8003
|
+
// `queuedStatusMsgIds` Map, so a restart between posting a card and its
|
|
8004
|
+
// promote/reap strands a permanent stale "⏳ Queued…" line. Persisted on POST,
|
|
8005
|
+
// cleared on in-process reap; a boot-time reaper (wired alongside
|
|
8006
|
+
// `activityCardBootReaper`, same startup-mutex ordering constraint) DELETES any
|
|
8007
|
+
// leftover card and clears the store — a "Queued" claim is always wrong after a
|
|
8008
|
+
// restart, so deletion is the honest terminal (reap-on-boot only, no
|
|
8009
|
+
// promote-across-restart). STATIC mode skips disk — same gate as the sibling
|
|
8010
|
+
// stores.
|
|
8011
|
+
const QUEUED_CARD_STORE_PATH = join(STATE_DIR, 'queued-cards-pending.json')
|
|
8012
|
+
const queuedCardStoreFs: QueuedCardStoreFsSeam = {
|
|
8013
|
+
readFileSync: (p: string) => readFileSync(p, 'utf8'),
|
|
8014
|
+
writeFileSync: (p: string, d: string) => writeFileSync(p, d),
|
|
8015
|
+
renameSync: (a: string, b: string) => renameSync(a, b),
|
|
8016
|
+
existsSync: (p: string) => existsSync(p),
|
|
8017
|
+
}
|
|
8018
|
+
const queuedCardPersistEnabled = !STATIC
|
|
8019
|
+
|
|
8020
|
+
// Write-through helpers (#3002) — mirror the in-memory `queuedStatusMsgIds`
|
|
8021
|
+
// set/delete onto the durable store. Best-effort + gated: no-op when the store
|
|
8022
|
+
// isn't usable (STATIC = no durable volume). Called from postQueuedStatus /
|
|
8023
|
+
// postBusyAck (post) and reapQueuedStatus (delete).
|
|
8024
|
+
function persistQueuedCard(
|
|
8025
|
+
key: string,
|
|
8026
|
+
chatId: string,
|
|
8027
|
+
threadId: number | null,
|
|
8028
|
+
messageId: number,
|
|
8029
|
+
): void {
|
|
8030
|
+
if (!queuedCardPersistEnabled) return
|
|
8031
|
+
writeQueuedCardRecord(QUEUED_CARD_STORE_PATH, queuedCardStoreFs, {
|
|
8032
|
+
key,
|
|
8033
|
+
chatId,
|
|
8034
|
+
threadId,
|
|
8035
|
+
messageId,
|
|
8036
|
+
})
|
|
8037
|
+
}
|
|
8038
|
+
function clearQueuedCard(key: string, messageId?: number): void {
|
|
8039
|
+
if (!queuedCardPersistEnabled) return
|
|
8040
|
+
clearQueuedCardRecord(QUEUED_CARD_STORE_PATH, queuedCardStoreFs, key, messageId)
|
|
8041
|
+
}
|
|
8042
|
+
|
|
7231
8043
|
// Slot-banner pin persistence (#421 crash-recovery). The slot banner is pinned
|
|
7232
8044
|
// in the owner chat when the agent is on a non-default OAuth slot. Rather than a
|
|
7233
8045
|
// parallel store + second boot hook, its pin is persisted in the SAME
|
|
@@ -7395,6 +8207,40 @@ async function activityCardBootReaper(): Promise<void> {
|
|
|
7395
8207
|
)
|
|
7396
8208
|
}
|
|
7397
8209
|
}
|
|
8210
|
+
|
|
8211
|
+
/**
|
|
8212
|
+
* Boot-time reaper for orphaned queued-status / busy-ack cards (#3002). Thin
|
|
8213
|
+
* gateway wrapper over the pure `runQueuedCardBootReaper` — binds the live fs
|
|
8214
|
+
* seam, a robust Telegram delete, and the logger. Deletes any card persisted by
|
|
8215
|
+
* a prior (crashed/restarted) session and clears the store: a "⏳ Queued…" /
|
|
8216
|
+
* "On it" claim is always wrong after a restart, so deletion is the honest
|
|
8217
|
+
* terminal (reap-on-boot only, no promote-across-restart).
|
|
8218
|
+
*
|
|
8219
|
+
* MUST run ONLY after this gateway wins the startup mutex — same shared-file
|
|
8220
|
+
* ordering constraint as `activityCardBootReaper` / `statusPinBootCleanup`.
|
|
8221
|
+
*/
|
|
8222
|
+
async function queuedCardBootReaper(): Promise<void> {
|
|
8223
|
+
if (!queuedCardPersistEnabled) return
|
|
8224
|
+
const { deleted, total } = await runQueuedCardBootReaper({
|
|
8225
|
+
path: QUEUED_CARD_STORE_PATH,
|
|
8226
|
+
fs: queuedCardStoreFs,
|
|
8227
|
+
deleteCard: (record) =>
|
|
8228
|
+
robustApiCall(
|
|
8229
|
+
() => lockedBot.api.deleteMessage(record.chatId, record.messageId),
|
|
8230
|
+
{
|
|
8231
|
+
chat_id: record.chatId,
|
|
8232
|
+
...(record.threadId != null ? { threadId: record.threadId } : {}),
|
|
8233
|
+
verb: 'queued-card.boot-reap-delete',
|
|
8234
|
+
},
|
|
8235
|
+
),
|
|
8236
|
+
})
|
|
8237
|
+
if (total > 0) {
|
|
8238
|
+
process.stderr.write(
|
|
8239
|
+
`telegram gateway: queued-card: deleted ${deleted}/${total} ` +
|
|
8240
|
+
`orphaned queued/busy-ack card(s) from a prior session\n`,
|
|
8241
|
+
)
|
|
8242
|
+
}
|
|
8243
|
+
}
|
|
7398
8244
|
// ─── Mid-session stale-card reaper (#2918) ──────────────────────────────────
|
|
7399
8245
|
// The boot reapers (markOrphanedWithTimeoutClassification + activityCardBoot-
|
|
7400
8246
|
// Reaper) run ONCE at startup. A turn whose owning SDK subprocess dies
|
|
@@ -7651,6 +8497,16 @@ async function reconcileStatusPinInner(
|
|
|
7651
8497
|
chatId,
|
|
7652
8498
|
prevState: prev,
|
|
7653
8499
|
desired,
|
|
8500
|
+
rightsCache: statusPinRightsCache,
|
|
8501
|
+
onPinRightsDisabled: (chat) => {
|
|
8502
|
+
// Logged ONCE per chat per process (#3024). Every subsequent auto-pin
|
|
8503
|
+
// attempt in this chat is skipped silently until a restart clears the
|
|
8504
|
+
// cache — replacing the 41x/48h `status-pin pin failed` spam marko saw.
|
|
8505
|
+
process.stderr.write(
|
|
8506
|
+
`telegram gateway: status-pin disabled for chat ${chat}: bot lacks ` +
|
|
8507
|
+
`pin rights; grant 'Pin messages' admin right to re-enable after restart\n`,
|
|
8508
|
+
)
|
|
8509
|
+
},
|
|
7654
8510
|
onError: (phase, err) => {
|
|
7655
8511
|
const msg = err instanceof Error ? err.message : String(err)
|
|
7656
8512
|
process.stderr.write(
|
|
@@ -7752,6 +8608,105 @@ async function unpinAllStatusPins(): Promise<void> {
|
|
|
7752
8608
|
}
|
|
7753
8609
|
}
|
|
7754
8610
|
|
|
8611
|
+
// ─── DM stale-pin sweep (#3026) ─────────────────────────────────────────────
|
|
8612
|
+
// In a user DM the Bot API skips the boot getChat() probe (positive chat IDs
|
|
8613
|
+
// return `400 chat not found` until the user messages) AND getChat() exposes
|
|
8614
|
+
// only the NEWEST pin — DMs STACK pins and there is no list-pins method, so
|
|
8615
|
+
// older orphan pins are invisible to the probe-based sweep forever. The durable
|
|
8616
|
+
// fix: once per DM chat per boot, `unpinAllChatMessages` (safe in a DM — every
|
|
8617
|
+
// pin is bot-authored) then re-pin the live tracked cards. Groups keep the
|
|
8618
|
+
// probe path (unpin-all there would nuke human pins).
|
|
8619
|
+
//
|
|
8620
|
+
// Eligibility mirrors statusPinBootCleanup's mutex gate: set true ONLY after
|
|
8621
|
+
// this gateway wins the startup lock, so a losing double-boot never clears the
|
|
8622
|
+
// live holder's pins.
|
|
8623
|
+
let dmPinSweepEligible = false
|
|
8624
|
+
const dmPinSweeper: DmPinSweeper = createDmPinSweeper({
|
|
8625
|
+
unpinAll: (chatId) =>
|
|
8626
|
+
robustApiCall(() => lockedBot.api.unpinAllChatMessages(chatId), {
|
|
8627
|
+
chat_id: chatId,
|
|
8628
|
+
verb: 'dm-pin-sweep.unpin-all',
|
|
8629
|
+
}),
|
|
8630
|
+
pinSilent: (chatId, messageId) =>
|
|
8631
|
+
robustApiCall(
|
|
8632
|
+
() =>
|
|
8633
|
+
lockedBot.api.pinChatMessage(chatId, messageId, {
|
|
8634
|
+
disable_notification: true,
|
|
8635
|
+
}),
|
|
8636
|
+
{ chat_id: chatId, verb: 'dm-pin-sweep.repin' },
|
|
8637
|
+
),
|
|
8638
|
+
// Pins that must survive the unpin-all: live in-memory status-pin claims
|
|
8639
|
+
// for this chat (fg:/wk:/tool:/banner: — non-empty for a first-inbound
|
|
8640
|
+
// sweep landing mid-turn) UNIONED with the deliberately-retained store
|
|
8641
|
+
// rows — unexpired `tool:` pins (#3001) survive statusPinBootCleanup by
|
|
8642
|
+
// design and must survive this sweep too. Read LIVE from the store so
|
|
8643
|
+
// both the boot sweep (in-memory maps still empty then) and a later
|
|
8644
|
+
// first-inbound sweep see them. Best-effort: a store read failure
|
|
8645
|
+
// degrades to in-memory-only. The sweeper dedupes.
|
|
8646
|
+
liveTrackedMessageIds: (chatId) => {
|
|
8647
|
+
const ids: number[] = []
|
|
8648
|
+
for (const [key, st] of statusPinState.entries()) {
|
|
8649
|
+
if (statusPinChatIds.get(key) === chatId) ids.push(st.messageId)
|
|
8650
|
+
}
|
|
8651
|
+
if (statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled) {
|
|
8652
|
+
try {
|
|
8653
|
+
ids.push(
|
|
8654
|
+
...unexpiredStoreRepinIds(
|
|
8655
|
+
loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs),
|
|
8656
|
+
chatId,
|
|
8657
|
+
Date.now(),
|
|
8658
|
+
),
|
|
8659
|
+
)
|
|
8660
|
+
} catch (err) {
|
|
8661
|
+
process.stderr.write(
|
|
8662
|
+
`telegram gateway: dm-pin-sweep: store repin scan failed ` +
|
|
8663
|
+
`(chat=${chatId}): ${(err as Error).message}\n`,
|
|
8664
|
+
)
|
|
8665
|
+
}
|
|
8666
|
+
}
|
|
8667
|
+
return ids
|
|
8668
|
+
},
|
|
8669
|
+
eligible: () => dmPinSweepEligible,
|
|
8670
|
+
log: (line) => process.stderr.write(line),
|
|
8671
|
+
})
|
|
8672
|
+
|
|
8673
|
+
/**
|
|
8674
|
+
* Boot-time pin cleanup + DM stale-pin sweep, sequenced under the startup
|
|
8675
|
+
* mutex. Collects the DM chat IDs with a prior-session pin record BEFORE the
|
|
8676
|
+
* store reapers empty the stores, runs the three existing boot reapers, marks
|
|
8677
|
+
* the DM sweep eligible (this gateway now owns the shared state), then
|
|
8678
|
+
* unpin-alls each recorded DM chat. Fire-and-forget from the caller — never
|
|
8679
|
+
* blocks boot, never rejects unhandled.
|
|
8680
|
+
*/
|
|
8681
|
+
async function runBootPinCleanupAndDmSweep(): Promise<void> {
|
|
8682
|
+
let dmChatIds: string[] = []
|
|
8683
|
+
try {
|
|
8684
|
+
dmChatIds = collectDmChatIdsFromStores({
|
|
8685
|
+
statusPins:
|
|
8686
|
+
statusPinPersistEnabled || bannerPinPersistEnabled || toolPinPersistEnabled
|
|
8687
|
+
? loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs)
|
|
8688
|
+
: [],
|
|
8689
|
+
activityCards: activityCardPersistEnabled
|
|
8690
|
+
? loadActivityCards(ACTIVITY_CARD_STORE_PATH, activityCardStoreFs)
|
|
8691
|
+
: [],
|
|
8692
|
+
queuedCards: queuedCardPersistEnabled
|
|
8693
|
+
? loadQueuedCards(QUEUED_CARD_STORE_PATH, queuedCardStoreFs)
|
|
8694
|
+
: [],
|
|
8695
|
+
})
|
|
8696
|
+
} catch (err) {
|
|
8697
|
+
process.stderr.write(
|
|
8698
|
+
`telegram gateway: dm-pin-sweep: store scan failed: ${(err as Error).message}\n`,
|
|
8699
|
+
)
|
|
8700
|
+
}
|
|
8701
|
+
await statusPinBootCleanup()
|
|
8702
|
+
await activityCardBootReaper()
|
|
8703
|
+
await queuedCardBootReaper()
|
|
8704
|
+
// This gateway now owns the shared per-agent pin state — enable the DM
|
|
8705
|
+
// unpin-all path (both the boot sweep below and lazy first-inbound sweeps).
|
|
8706
|
+
dmPinSweepEligible = true
|
|
8707
|
+
for (const id of dmChatIds) await dmPinSweeper.sweep(id)
|
|
8708
|
+
}
|
|
8709
|
+
|
|
7755
8710
|
// Activity feed. The gateway streams a live "what it's doing" tool-activity
|
|
7756
8711
|
// feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
|
|
7757
8712
|
// call (flush-independent, so it stays real-time on fast/clustered-tool
|
|
@@ -7904,8 +8859,9 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
7904
8859
|
// pins from a prior (dead) session. Gated here (not at import time) so a
|
|
7905
8860
|
// LOSING double-boot never unpins the live holder's legitimate pins.
|
|
7906
8861
|
// Fire-and-forget: cleanup is best-effort and must not block boot.
|
|
7907
|
-
|
|
7908
|
-
|
|
8862
|
+
// #3026: sequenced so the DM stale-pin sweep runs after the reapers and
|
|
8863
|
+
// only once this gateway owns the shared state.
|
|
8864
|
+
void runBootPinCleanupAndDmSweep()
|
|
7909
8865
|
} catch (err) {
|
|
7910
8866
|
process.stderr.write(
|
|
7911
8867
|
`telegram gateway: boot.lock_acquire_failed err=${(err as Error).message} agent=${SWITCHROOM_AGENT_NAME}\n`,
|
|
@@ -7921,8 +8877,8 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
|
|
|
7921
8877
|
// probe + 409-retry loop is still the liveness guard on this path. A
|
|
7922
8878
|
// successful writePidFile here means no live holder was detected, so
|
|
7923
8879
|
// running orphan cleanup is consistent with the pre-mutex behaviour.
|
|
7924
|
-
|
|
7925
|
-
void
|
|
8880
|
+
// #3026: same sequenced cleanup + DM stale-pin sweep as the mutex path.
|
|
8881
|
+
void runBootPinCleanupAndDmSweep()
|
|
7926
8882
|
} catch (writeErr) {
|
|
7927
8883
|
process.stderr.write(`telegram gateway: writePidFile failed: ${writeErr}\n`)
|
|
7928
8884
|
}
|
|
@@ -8644,6 +9600,13 @@ pendingProgress.startTimer({
|
|
|
8644
9600
|
chat_id: ctx.chatId,
|
|
8645
9601
|
verb: 'pending-progress-edit',
|
|
8646
9602
|
...(ctx.threadId != null ? { threadId: ctx.threadId } : {}),
|
|
9603
|
+
// #3084 PR 2 / L1: the progress-card edit is the #1 documented ban
|
|
9604
|
+
// trigger (repeated editMessageText on one message). Tag COSMETIC and
|
|
9605
|
+
// pass messageId/editPayload so the gate's per-message floor + no-op
|
|
9606
|
+
// skip + coalescing engage, and the edit sheds while a window is open.
|
|
9607
|
+
priorityClass: 'cosmetic',
|
|
9608
|
+
messageId: ctx.messageId,
|
|
9609
|
+
editPayload: ctx.literalText ? ctx.newText : richMessage(ctx.newText),
|
|
8647
9610
|
},
|
|
8648
9611
|
)
|
|
8649
9612
|
},
|
|
@@ -9256,6 +10219,16 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
9256
10219
|
// this call is safe wherever it sits relative to the cron early-return
|
|
9257
10220
|
// above (#3038 review finding 5).
|
|
9258
10221
|
bridgeDeadWatchdog.noteBridgeRegistered(client.agentName)
|
|
10222
|
+
// #3043 item 2: a REAL bridge registering is proof the boot came all the
|
|
10223
|
+
// way up healthy — clear start.sh's crashloop boot-attempts counter so only
|
|
10224
|
+
// boots that genuinely fail BEFORE the bridge registers accumulate toward
|
|
10225
|
+
// the 3-strike override clear. Without this, three quick operator
|
|
10226
|
+
// hand-bounces of a healthy agent (each <150s apart) spuriously wipe a
|
|
10227
|
+
// working model override. Best-effort; no-op when the file is absent.
|
|
10228
|
+
if (client.agentName != null) {
|
|
10229
|
+
const smBootDir = resolveAgentDirFromEnv()
|
|
10230
|
+
if (smBootDir != null) clearSessionModelBootAttempts(smBootDir)
|
|
10231
|
+
}
|
|
9259
10232
|
client.send({ type: 'status', status: 'agent_connected' })
|
|
9260
10233
|
|
|
9261
10234
|
// Phase 2b PR 3a — bridgeUp cutover. The state machine's `bridgeUp`
|
|
@@ -9480,6 +10453,16 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
9480
10453
|
// scripts/check-plugin-references.mjs (TS2722).
|
|
9481
10454
|
progressDriver?.dispose?.({ preservePending: true })
|
|
9482
10455
|
},
|
|
10456
|
+
// #2650: the bridge died mid-turn — its turn-long `typing…` loop never
|
|
10457
|
+
// hits the canonical turn-end stop, leaving a stale "typing…" until the
|
|
10458
|
+
// next turn. Sweep every live loop here (gated to registered-agent
|
|
10459
|
+
// disconnect inside flushOnAgentDisconnect).
|
|
10460
|
+
stopTurnTypingLoops: () => {
|
|
10461
|
+
turnTypingLoop.stopAll()
|
|
10462
|
+
// Shutdown drain: also cancel every armed catch-up so no typing ping
|
|
10463
|
+
// fires after the gateway has stopped.
|
|
10464
|
+
typingEmitter.reset()
|
|
10465
|
+
},
|
|
9483
10466
|
// When dangling activeTurnStartedAt keys were swept (setDone raced
|
|
9484
10467
|
// disconnect), the module-scope `currentTurn` may also point at the
|
|
9485
10468
|
// dead bridge's turn. Null it so the next inbound starts a fresh
|
|
@@ -9702,7 +10685,8 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
9702
10685
|
// `card_text` is retained so the TTL reaper can re-edit the SAME body
|
|
9703
10686
|
// with a "timed out" footer while stripping the keyboard atomically
|
|
9704
10687
|
// (Bug 2 fix #1) — the reaper has no grammy ctx to read the live text.
|
|
9705
|
-
|
|
10688
|
+
const pendEntry = { tool_name: toolName, description, input_preview: inputPreview, startedAt: Date.now(), card_text: text, cards: [] }
|
|
10689
|
+
pendingPermissions.set(requestId, pendEntry)
|
|
9706
10690
|
// Compact action row: ❌ Deny · ✅ Allow · 🔁 Always… — the scope of an
|
|
9707
10691
|
// "always" grant stays hidden until the operator taps "🔁 Always…",
|
|
9708
10692
|
// which swaps the row for a scope choice (this file / any file ⚠️). The
|
|
@@ -9710,67 +10694,15 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
9710
10694
|
// rule for this tool; unknown tools get the two-button row only. "Allow"
|
|
9711
10695
|
// itself auto-grants a 30-min window for narrow non-destructive scopes
|
|
9712
10696
|
// (decided in the allow handler), so there is no separate time-box button.
|
|
9713
|
-
|
|
9714
|
-
|
|
9715
|
-
//
|
|
9716
|
-
// lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
|
|
9717
|
-
// there's an active turn — so a supergroup agent's card appears IN the
|
|
9718
|
-
// topic the operator asked from (marko's "CRM (Brevo)"), not the
|
|
9719
|
-
// operator DM — else the configured operator DMs, thread-stripped. The
|
|
9720
|
-
// old code iterated `allowFrom` unconditionally, so a supergroup card
|
|
9721
|
-
// could only ever reach operator DMs (the topic chat id is never in
|
|
9722
|
-
// `allowFrom`) (marko, 2026-06-03).
|
|
10697
|
+
// Captured BEFORE the send: the status-reaction parking below needs the
|
|
10698
|
+
// turn that raised this card, and `currentTurn` can be re-pointed by a
|
|
10699
|
+
// concurrent inbound while the send is in flight.
|
|
9723
10700
|
const activeTurn = currentTurn
|
|
9724
|
-
|
|
9725
|
-
|
|
9726
|
-
|
|
9727
|
-
|
|
9728
|
-
|
|
9729
|
-
// found"), re-send thread-less into the main chat so the card still
|
|
9730
|
-
// ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
|
|
9731
|
-
// allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware send
|
|
9732
|
-
void retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
|
|
9733
|
-
robustApiCall,
|
|
9734
|
-
(tid) =>
|
|
9735
|
-
bot.api.sendRichMessage(chatId, richMessage(text), {
|
|
9736
|
-
reply_markup: keyboard,
|
|
9737
|
-
...(tid != null ? { message_thread_id: tid } : {}),
|
|
9738
|
-
}),
|
|
9739
|
-
{ threadId, chat_id: chatId, verb: 'permission_request' },
|
|
9740
|
-
).then(sent => {
|
|
9741
|
-
// Record the live card's (chat, message) so the TTL reaper can strip
|
|
9742
|
-
// its inline keyboard on auto-deny — a stale Approve button left
|
|
9743
|
-
// tappable dispatches a verdict for a dead request_id (Bug 2). The
|
|
9744
|
-
// entry may already be gone (operator tapped before this resolved);
|
|
9745
|
-
// guard the lookup.
|
|
9746
|
-
const pend = pendingPermissions.get(requestId)
|
|
9747
|
-
if (pend && sent && typeof sent.message_id === 'number') {
|
|
9748
|
-
// #2787: record where the card ACTUALLY LANDED so the confirm sweep
|
|
9749
|
-
// scopes its re-delivery suspension to the real chat/topic. When
|
|
9750
|
-
// retryWithThreadFallback hit THREAD_NOT_FOUND (stale/renumbered
|
|
9751
|
-
// topic) it re-sent thread-less into the main chat — the returned
|
|
9752
|
-
// Message then carries no message_thread_id, so we must record the
|
|
9753
|
-
// landed topic (undefined → suspend by bare chatId), NOT the stale
|
|
9754
|
-
// requested `threadId`. Keying suspension on the stale topic would
|
|
9755
|
-
// leave the main-chat card unsuspended (a re-deliver could clobber
|
|
9756
|
-
// the live card) while needlessly suspending a topic that holds
|
|
9757
|
-
// nothing. `sent.message_thread_id` reflects reality in all three
|
|
9758
|
-
// cases: topic success → tid, main-chat / fallback → undefined.
|
|
9759
|
-
const landedThreadId = sent.message_thread_id ?? undefined
|
|
9760
|
-
pend.cards.push({ chatId, messageId: sent.message_id, threadId: landedThreadId })
|
|
9761
|
-
permCardStore.add({
|
|
9762
|
-
requestId,
|
|
9763
|
-
chatId,
|
|
9764
|
-
messageId: sent.message_id,
|
|
9765
|
-
startedAt: pend.startedAt,
|
|
9766
|
-
toolName: pend.tool_name,
|
|
9767
|
-
cardText: pend.card_text,
|
|
9768
|
-
})
|
|
9769
|
-
}
|
|
9770
|
-
}).catch(e => {
|
|
9771
|
-
process.stderr.write(`telegram gateway: permission_request send to ${chatId} failed: ${e}\n`)
|
|
9772
|
-
})
|
|
9773
|
-
}
|
|
10701
|
+
// #3084 follow-up — the send now lives in postPermissionCard() so the
|
|
10702
|
+
// reaper can RE-DRIVE it when a flood window closes. This is the first
|
|
10703
|
+
// delivery attempt; if it fails against an open ban the entry is marked
|
|
10704
|
+
// undeliverable and held, never auto-denied.
|
|
10705
|
+
postPermissionCard(requestId, pendEntry)
|
|
9774
10706
|
// Park the turn's status reaction on 🙏 (awaiting your tap) and
|
|
9775
10707
|
// suspend the stall watchdog — a turn blocked on the operator is not
|
|
9776
10708
|
// stalled, so it must not degrade to 🥱/😨 while the card sits
|
|
@@ -10481,6 +11413,36 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
10481
11413
|
}
|
|
10482
11414
|
},
|
|
10483
11415
|
|
|
11416
|
+
// #2975 Stage 2 — read-only pre-approval query from hostd. Answer whether the
|
|
11417
|
+
// EXACT (agent, diff) pair is already operator-consented so hostd can skip the
|
|
11418
|
+
// config_propose_edit rate limit for that persist. NEVER mutates state: it
|
|
11419
|
+
// only peeks the correlation maps by byte-exact match (isDiffPreApproved).
|
|
11420
|
+
// Fail-closed by construction — any agent mismatch or missing correlation
|
|
11421
|
+
// answers `preApproved: false`.
|
|
11422
|
+
onCheckPreApproved(client: IpcClient, msg: CheckPreApprovedMessage) {
|
|
11423
|
+
let preApproved = false
|
|
11424
|
+
try {
|
|
11425
|
+
const self = process.env.SWITCHROOM_AGENT_NAME
|
|
11426
|
+
// This gateway serves exactly one agent; a query for a different agent
|
|
11427
|
+
// can never be pre-approved here.
|
|
11428
|
+
if (!self || msg.agentName === self) {
|
|
11429
|
+
preApproved = isDiffPreApprovedLive(msg.agentName, msg.unifiedDiff)
|
|
11430
|
+
}
|
|
11431
|
+
} catch (err) {
|
|
11432
|
+
process.stderr.write(
|
|
11433
|
+
`telegram gateway: check_pre_approved errored — ${(err as Error).message} (failing closed)\n`,
|
|
11434
|
+
)
|
|
11435
|
+
preApproved = false
|
|
11436
|
+
}
|
|
11437
|
+
try {
|
|
11438
|
+
client.send({ type: 'pre_approved_result', correlationId: msg.correlationId, preApproved })
|
|
11439
|
+
} catch (err) {
|
|
11440
|
+
process.stderr.write(
|
|
11441
|
+
`telegram gateway: check_pre_approved reply failed: ${(err as Error).message}\n`,
|
|
11442
|
+
)
|
|
11443
|
+
}
|
|
11444
|
+
},
|
|
11445
|
+
|
|
10484
11446
|
// #2670 one-tap self-improvement — persist a skill-improvement proposal and
|
|
10485
11447
|
// post its Approve/Dismiss card. The store transition + apply-injection on
|
|
10486
11448
|
// Approve are owned by handleSkillProposalCallback (so a gateway restart
|
|
@@ -10824,10 +11786,21 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
|
|
|
10824
11786
|
|
|
10825
11787
|
assertAllowedChat(chat_id)
|
|
10826
11788
|
|
|
10827
|
-
|
|
10828
|
-
|
|
11789
|
+
// #2044 outbound secret scrub. Checklist title + task strings are
|
|
11790
|
+
// agent-authored free text sent to Telegram, same class as the reply
|
|
11791
|
+
// `text` path and ask_user — route both through the SAME redactor before
|
|
11792
|
+
// send (via the pure, unit-tested redactChecklistFields helper) so an
|
|
11793
|
+
// echoed token / DATABASE_URL is masked.
|
|
11794
|
+
const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
|
|
10829
11795
|
title,
|
|
10830
11796
|
tasks,
|
|
11797
|
+
(t) => redactOutboundText(t, 'send_checklist'),
|
|
11798
|
+
)
|
|
11799
|
+
|
|
11800
|
+
const sent = await rawSendChecklist({
|
|
11801
|
+
chat_id,
|
|
11802
|
+
title: redactedTitle!,
|
|
11803
|
+
tasks: redactedTasks!,
|
|
10831
11804
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
10832
11805
|
...(replyTo != null ? { reply_to_message_id: replyTo } : {}),
|
|
10833
11806
|
...(protectContent ? { protect_content: true } : {}),
|
|
@@ -10909,7 +11882,19 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
|
|
|
10909
11882
|
|
|
10910
11883
|
assertAllowedChat(chat_id)
|
|
10911
11884
|
|
|
10912
|
-
|
|
11885
|
+
// #2044 outbound secret scrub. update_checklist forwards the same
|
|
11886
|
+
// agent-authored title + task text to Telegram as send_checklist, so it
|
|
11887
|
+
// shares the identical leak class — redact both through the reply-path
|
|
11888
|
+
// redactor (via the shared redactChecklistFields helper) before the edit
|
|
11889
|
+
// lands. title / tasks may be undefined here (partial patch); the helper
|
|
11890
|
+
// passes undefined through untouched.
|
|
11891
|
+
const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
|
|
11892
|
+
title,
|
|
11893
|
+
tasks,
|
|
11894
|
+
(t) => redactOutboundText(t, 'update_checklist'),
|
|
11895
|
+
)
|
|
11896
|
+
|
|
11897
|
+
await rawEditMessageChecklist({ chat_id, message_id, title: redactedTitle, tasks: redactedTasks })
|
|
10913
11898
|
|
|
10914
11899
|
process.stderr.write(`telegram gateway: update_checklist: updated chatId=${chat_id} messageId=${message_id}\n`)
|
|
10915
11900
|
return { content: [{ type: 'text', text: `checklist updated (id: ${message_id})` }] }
|
|
@@ -11637,8 +12622,18 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
11637
12622
|
.join('; ')
|
|
11638
12623
|
throw new Error(`inline_keyboard validation failed: ${summary}`)
|
|
11639
12624
|
}
|
|
11640
|
-
|
|
11641
|
-
|
|
12625
|
+
// #3148 fast-follow: mask any secret an agent put in a visible button
|
|
12626
|
+
// `text` label, its `ack_text` toast, or a `copy_text.text` clipboard
|
|
12627
|
+
// payload BEFORE the keyboard is sent — the same outbound scrub the reply
|
|
12628
|
+
// `text` body uses. `callback_data` (the routing key) is left exact.
|
|
12629
|
+
// Feeding BOTH the meta extraction and the callback wrap from the redacted
|
|
12630
|
+
// copy means the stashed toast, the tap echo (`button_text`), and the
|
|
12631
|
+
// "✅ You chose: <label>" annotation (#789) all read already-masked bytes.
|
|
12632
|
+
const redactedKeyboard = redactAgentKeyboard(rawKeyboard, (s) =>
|
|
12633
|
+
redactOutboundText(s, 'reply_inline_keyboard'),
|
|
12634
|
+
)
|
|
12635
|
+
replyButtonMeta = extractAgentButtonMeta(redactedKeyboard)
|
|
12636
|
+
replyMarkup = { inline_keyboard: wrapAgentCallbacks(redactedKeyboard) }
|
|
11642
12637
|
}
|
|
11643
12638
|
|
|
11644
12639
|
// on-demand voice: append a single '🔊 Listen' button that synthesizes the
|
|
@@ -11963,13 +12958,15 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
11963
12958
|
robustApiCall(
|
|
11964
12959
|
// allow-raw-bot-api: injected chunk-loop adapter — sendRichMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks' fallback ladder
|
|
11965
12960
|
() => lockedBot.api.sendRichMessage(chat_id, body as never, opts as never),
|
|
11966
|
-
|
|
12961
|
+
// #3084 PR 2: the final reply is CRITICAL — never shed; degraded mode
|
|
12962
|
+
// fails fast (structured flood_wait) instead of blocking the MCP reply.
|
|
12963
|
+
{ threadId: tid, chat_id, priorityClass: 'critical' },
|
|
11967
12964
|
),
|
|
11968
12965
|
sendLiteral: (opts, txt, tid) =>
|
|
11969
12966
|
robustApiCall(
|
|
11970
12967
|
// allow-raw-bot-api: injected chunk-loop adapter — literal format:'text' send routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks
|
|
11971
12968
|
() => lockedBot.api.sendMessage(chat_id, txt, opts as never),
|
|
11972
|
-
{ threadId: tid, chat_id },
|
|
12969
|
+
{ threadId: tid, chat_id, priorityClass: 'critical' },
|
|
11973
12970
|
),
|
|
11974
12971
|
sendLiteralRaw: (opts, txt) =>
|
|
11975
12972
|
// allow-raw-bot-api: literal last-resort fallback (plaintext parse-reject / length re-split); wrapping would re-enter the parse/length policy that just rejected the payload
|
|
@@ -11981,7 +12978,10 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
11981
12978
|
robustApiCall(
|
|
11982
12979
|
// allow-raw-bot-api: preview edit-in-place routed through robustApiCall; thread fallback handled by sendReplyChunks
|
|
11983
12980
|
() => lockedBot.api.editMessageText(chat_id, mid, body as never, opts as never),
|
|
11984
|
-
|
|
12981
|
+
// Finalizing the reply into the preview message — still CRITICAL (this
|
|
12982
|
+
// IS the answer). Pass messageId/editPayload so the gate's per-message
|
|
12983
|
+
// floor + no-op skip engage on the edit (part3-design §4/§5, PR1 L1).
|
|
12984
|
+
{ threadId: tid, chat_id, priorityClass: 'critical', messageId: mid, editPayload: body },
|
|
11985
12985
|
),
|
|
11986
12986
|
richMessage,
|
|
11987
12987
|
logOutbound,
|
|
@@ -12604,6 +13604,21 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
|
|
|
12604
13604
|
const args = validateAskUserArgs(rawArgs as unknown as AskUserArgs)
|
|
12605
13605
|
assertAllowedChat(args.chatId)
|
|
12606
13606
|
|
|
13607
|
+
// #2044 outbound secret scrub. The question text and each option/button
|
|
13608
|
+
// label are agent-authored free text sent to Telegram, exactly like the
|
|
13609
|
+
// reply `text` path — an agent that echoes a token or DATABASE_URL it just
|
|
13610
|
+
// read into a question or a button label would transmit it unmasked. Route
|
|
13611
|
+
// both through the SAME redactor as reply (via the pure, unit-tested
|
|
13612
|
+
// redactAskUserFields helper), assigning back onto the validated args so
|
|
13613
|
+
// every downstream consumer — the send, the timeout-edit re-render, and
|
|
13614
|
+
// the tap-echo of the chosen label stored in pendingAskUser — sees the
|
|
13615
|
+
// masked value.
|
|
13616
|
+
const scrubbed = redactAskUserFields(args.question, args.options, (t) =>
|
|
13617
|
+
redactOutboundText(t, 'ask_user'),
|
|
13618
|
+
)
|
|
13619
|
+
args.question = scrubbed.question
|
|
13620
|
+
args.options = scrubbed.options
|
|
13621
|
+
|
|
12607
13622
|
// Resolve thread + reply-to using the same auto-thread heuristic
|
|
12608
13623
|
// executeReply uses, so an agent that omits message_thread_id still
|
|
12609
13624
|
// routes into the right forum topic and quotes the user's last
|
|
@@ -13704,9 +14719,11 @@ async function executeReact(args: Record<string, unknown>): Promise<unknown> {
|
|
|
13704
14719
|
if (!args.message_id) throw new Error('react: message_id is required')
|
|
13705
14720
|
if (!args.emoji) throw new Error('react: emoji is required')
|
|
13706
14721
|
assertAllowedChat(String(args.chat_id ?? ''))
|
|
13707
|
-
await
|
|
13708
|
-
|
|
13709
|
-
|
|
14722
|
+
await sendReaction(
|
|
14723
|
+
String(args.chat_id ?? ''),
|
|
14724
|
+
Number(args.message_id),
|
|
14725
|
+
args.emoji as ReactionTypeEmoji['emoji'],
|
|
14726
|
+
)
|
|
13710
14727
|
return { content: [{ type: 'text', text: 'reacted' }] }
|
|
13711
14728
|
}
|
|
13712
14729
|
|
|
@@ -13846,6 +14863,12 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
|
|
|
13846
14863
|
() => lockedBot.api.pinChatMessage(pinChatId, pinMsgId),
|
|
13847
14864
|
{ chat_id: pinChatId, verb: 'pin_message' },
|
|
13848
14865
|
)
|
|
14866
|
+
// An explicit pin succeeded here, so the bot demonstrably HAS pin rights in
|
|
14867
|
+
// this chat now — clear any auto-pin negative-cache entry (#3024) so the auto
|
|
14868
|
+
// status-pin path resumes immediately rather than waiting for a restart. The
|
|
14869
|
+
// explicit tool itself never consults the cache; a failure above still
|
|
14870
|
+
// surfaces to the agent as a normal tool error (robustApiCall rethrows).
|
|
14871
|
+
statusPinRightsCache.clear(pinChatId)
|
|
13849
14872
|
// #3001: register the tool pin in the shared status-pin store under a
|
|
13850
14873
|
// `tool:` key so it is no longer fire-and-forget. Unlike work-scoped
|
|
13851
14874
|
// fg:/wk: rows a tool pin has no "work finished" event, so a restart does
|
|
@@ -14962,6 +15985,21 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
14962
15985
|
liveTurn.liveness.onStreamEvent(ev.kind, durationMs, Date.now())
|
|
14963
15986
|
}
|
|
14964
15987
|
}
|
|
15988
|
+
// Idle-clear clocks (#3084 follow-up). EVERY genuine session event is
|
|
15989
|
+
// activity — an agent that is thinking, calling a tool, streaming text or
|
|
15990
|
+
// driving a sub-agent is NOT idle, whether or not the gateway currently has a
|
|
15991
|
+
// turn open. Stamping only at turn START (the old behaviour) is what let a
|
|
15992
|
+
// 3-hour working stretch be scored as zero activity and `/clear`ed the moment
|
|
15993
|
+
// the window elapsed. A turn ending stamps the turn-end clock too, so a turn
|
|
15994
|
+
// that outran the window is not wiped the instant `turnInFlight` goes false.
|
|
15995
|
+
// This runs for the whole event stream, including every `sub_agent_*` kind —
|
|
15996
|
+
// background workers keep the timer warm exactly as long as they are working.
|
|
15997
|
+
{
|
|
15998
|
+
const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
|
|
15999
|
+
const signal = classifyIdleEvent(ev.kind, durationMs)
|
|
16000
|
+
if (signal.activity) markIdleActivity()
|
|
16001
|
+
if (signal.turnEnded) markIdleTurnEnd()
|
|
16002
|
+
}
|
|
14965
16003
|
switch (ev.kind) {
|
|
14966
16004
|
case 'enqueue': {
|
|
14967
16005
|
// Drain any orphaned typing-wrap entries left over from a crashed
|
|
@@ -15100,7 +16138,9 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
15100
16138
|
// per-topic `byKey[statusKey]` entry AND the most-recent mirror. The key is
|
|
15101
16139
|
// the SAME statusKey the ctor's façade was constructed with just above.
|
|
15102
16140
|
setCurrentTurn(next, statusKey(ev.chatId, enqThreadIdNum))
|
|
15103
|
-
|
|
16141
|
+
// (turn start already stamped the idle clock at the top of
|
|
16142
|
+
// handleSessionEvent, along with every other session event — see the
|
|
16143
|
+
// idle-clear block there.)
|
|
15104
16144
|
// Early-open the "Working…" liveness card at turn start so narration /
|
|
15105
16145
|
// thinking emitted BEFORE the first tool surfaces within ~a second
|
|
15106
16146
|
// instead of after the old 12 s threshold (the dead-air gap). Fires the
|
|
@@ -15584,6 +16624,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
15584
16624
|
chat_id: chatId,
|
|
15585
16625
|
verb: 'answer-stream.editMessageText',
|
|
15586
16626
|
...(tid != null ? { threadId: tid } : {}),
|
|
16627
|
+
// #3084 PR 2 / L1: answer-stream edits are COSMETIC — a
|
|
16628
|
+
// dropped stream tick costs nothing (the next carries full
|
|
16629
|
+
// text). messageId/editPayload engage the per-message floor +
|
|
16630
|
+
// coalescing + no-op skip so rapid stream edits don't storm
|
|
16631
|
+
// the same message (the top ban trigger).
|
|
16632
|
+
priorityClass: 'cosmetic',
|
|
16633
|
+
messageId,
|
|
16634
|
+
editPayload: richMessage(text),
|
|
15587
16635
|
},
|
|
15588
16636
|
)
|
|
15589
16637
|
},
|
|
@@ -15777,6 +16825,24 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
15777
16825
|
return
|
|
15778
16826
|
}
|
|
15779
16827
|
}
|
|
16828
|
+
// #2094 finding 1 — turn_end gate-wedge backstop. Capture the turn
|
|
16829
|
+
// BEFORE the body runs (the body re-reads currentTurn as `turn`, then
|
|
16830
|
+
// nulls it via endCurrentTurnAtomic on every clean branch). The guarded
|
|
16831
|
+
// finally in withTurnEndGateBackstop forces the canonical purge iff a
|
|
16832
|
+
// throw in a pre-purge op (redactOutboundText, progressDriver?.
|
|
16833
|
+
// takeOverCard, narrative dedup, answer-stream finalize, …) skipped
|
|
16834
|
+
// endCurrentTurnAtomic → purgeReactionTracking, which would otherwise
|
|
16835
|
+
// leave activeTurnStartedAt + claudeBusyKeys populated and wedge the
|
|
16836
|
+
// #1556 inbound gate closed. No-op on the happy path (key already gone).
|
|
16837
|
+
const turnEndBackstopTurn = currentTurn
|
|
16838
|
+
const turnEndBackstopKey =
|
|
16839
|
+
turnEndBackstopTurn != null
|
|
16840
|
+
? statusKey(turnEndBackstopTurn.sessionChatId, turnEndBackstopTurn.sessionThreadId)
|
|
16841
|
+
: null
|
|
16842
|
+
withTurnEndGateBackstop(
|
|
16843
|
+
turnEndBackstopKey,
|
|
16844
|
+
turnEndBackstopTurn,
|
|
16845
|
+
() => {
|
|
15780
16846
|
// Drain any still-pending tool dispatch typing entries — covers
|
|
15781
16847
|
// transcript truncation or a Claude Code crash mid-tool.
|
|
15782
16848
|
typingWrapper.drainAll()
|
|
@@ -16003,6 +17069,17 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16003
17069
|
capturedText: turn.capturedText,
|
|
16004
17070
|
flushEnabled: TURN_FLUSH_SAFETY_ENABLED,
|
|
16005
17071
|
})
|
|
17072
|
+
// #1667 — resolve the turn_end answer-delivery gate once, here, via the
|
|
17073
|
+
// pure decision core. The three dispositions below (silent-marker,
|
|
17074
|
+
// turn-flush, #1664 re-prompt) delegate to this so the gateway runs the
|
|
17075
|
+
// exact code the regression test exercises. `finalAnswerDelivered` is read
|
|
17076
|
+
// at its tail value: the answer-stream materialize branch above has
|
|
17077
|
+
// already run (and may have set it true); the flush branch, which also
|
|
17078
|
+
// sets it, is not yet entered and does not affect the gate's own outcome.
|
|
17079
|
+
const turnEndDecision = decideTurnEndGate({
|
|
17080
|
+
flushDecision,
|
|
17081
|
+
finalAnswerDelivered: turn.finalAnswerDelivered,
|
|
17082
|
+
})
|
|
16006
17083
|
if (flushDecision.kind === 'skip' && flushDecision.reason !== 'reply-called') {
|
|
16007
17084
|
process.stderr.write(
|
|
16008
17085
|
`telegram gateway: turn-flush skipped — reason=${flushDecision.reason}\n`,
|
|
@@ -16041,7 +17118,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16041
17118
|
// 2. NOT send any reply message to the user.
|
|
16042
17119
|
// 3. Unpin the progress card so no orphaned ⚙️ Working… lingers.
|
|
16043
17120
|
// 4. Log at debug level and fall through to normal state cleanup.
|
|
16044
|
-
if (
|
|
17121
|
+
if (turnEndDecision === 'silent_end') {
|
|
16045
17122
|
// Don't try to distinguish NO_REPLY vs HEARTBEAT_OK in the log line:
|
|
16046
17123
|
// `isSilentFlushMarker` accepts trailing punctuation (e.g. "NO_REPLY.")
|
|
16047
17124
|
// and case variants, so a strict equality check would print the wrong
|
|
@@ -16131,7 +17208,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16131
17208
|
return
|
|
16132
17209
|
}
|
|
16133
17210
|
|
|
16134
|
-
if (flushDecision.kind === 'flush') {
|
|
17211
|
+
if (turnEndDecision === 'flush' && flushDecision.kind === 'flush') {
|
|
16135
17212
|
let capturedText = flushDecision.text
|
|
16136
17213
|
// #2798 — turn-flush delivers the model's terminal prose when it
|
|
16137
17214
|
// skipped reply/stream_reply, but historically bypassed the reply
|
|
@@ -16267,9 +17344,12 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16267
17344
|
// the reaction is only finalized by the `turn_end` IPC
|
|
16268
17345
|
// handler — mid-turn delivery proofs (local history,
|
|
16269
17346
|
// stream finalize callbacks, executeReply post-send) no
|
|
16270
|
-
// longer transition the emoji. This branch just
|
|
16271
|
-
// the per-turn reaction tracking
|
|
16272
|
-
|
|
17347
|
+
// longer transition the emoji. This branch just returns.
|
|
17348
|
+
// #2094 cosmetic: the per-turn reaction tracking was ALREADY
|
|
17349
|
+
// purged synchronously by endCurrentTurnAtomic (before this
|
|
17350
|
+
// async IIFE ran). The old redundant purgeReactionTracking
|
|
17351
|
+
// here re-fired on an already-cleared key WITHOUT `endingTurn`,
|
|
17352
|
+
// emitting an inconsistent shadow trace. Removed.
|
|
16273
17353
|
return
|
|
16274
17354
|
}
|
|
16275
17355
|
} catch {}
|
|
@@ -16405,9 +17485,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16405
17485
|
// #1713: backstop send failed — finalize as error so the
|
|
16406
17486
|
// turn ends cleanly with 😱 rather than leaving it open.
|
|
16407
17487
|
if (backstopCtrl) backstopCtrl.finalize('error')
|
|
16408
|
-
} finally {
|
|
16409
|
-
purgeReactionTracking(statusKey(backstopChatId, backstopThreadId))
|
|
16410
17488
|
}
|
|
17489
|
+
// #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
|
|
17490
|
+
// was removed. endCurrentTurnAtomic already ran the canonical purge
|
|
17491
|
+
// (with the authoritative `endingTurn`) synchronously before this
|
|
17492
|
+
// async IIFE started, so re-purging here only re-fired on an
|
|
17493
|
+
// already-cleared key without `endingTurn` — an inconsistent shadow
|
|
17494
|
+
// trace. The #2094 finding-1 backstop covers any pre-purge throw.
|
|
16411
17495
|
})()
|
|
16412
17496
|
return
|
|
16413
17497
|
}
|
|
@@ -16496,7 +17580,10 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16496
17580
|
// HEARTBEAT_OK silent-marker turns return earlier and never reach
|
|
16497
17581
|
// this path. The turn-flush 'flush' branch also returns earlier
|
|
16498
17582
|
// (and sets finalAnswerDelivered=true defensively).
|
|
16499
|
-
|
|
17583
|
+
// #1667 — this is the reply-called tail; `turnEndDecision === 'reprompt'`
|
|
17584
|
+
// is exactly `turn.finalAnswerDelivered === false` here (silent-marker
|
|
17585
|
+
// and flush both returned earlier), delegated to the pure gate core.
|
|
17586
|
+
if (turnEndDecision === 'reprompt') {
|
|
16500
17587
|
// PR #2892 (deterministic-turn-liveness RFC Phase 2) hardening:
|
|
16501
17588
|
// wire the represent-guard-style staleness
|
|
16502
17589
|
// check (`recordSilentTurnEnd`'s `hasOutboundDeliveredSince` dep) so
|
|
@@ -16600,6 +17687,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16600
17687
|
// #549 fix — preamble flush already happened at the TOP of this
|
|
16601
17688
|
// turn_end handler (before turn.answerStream is nulled). See
|
|
16602
17689
|
// comment near line 3431.
|
|
17690
|
+
return
|
|
17691
|
+
}, // end withTurnEndGateBackstop body (#2094 finding 1)
|
|
17692
|
+
{
|
|
17693
|
+
hasActiveTurn: (k) => activeTurnStartedAt.has(k),
|
|
17694
|
+
purge: (k, endingTurn) => purgeReactionTracking(k, endingTurn),
|
|
17695
|
+
log: (m) => process.stderr.write(m + '\n'),
|
|
17696
|
+
},
|
|
17697
|
+
)
|
|
16603
17698
|
return
|
|
16604
17699
|
}
|
|
16605
17700
|
}
|
|
@@ -16607,6 +17702,17 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
16607
17702
|
|
|
16608
17703
|
// ─── PTY partial handler ─────────────────────────────────────────────────
|
|
16609
17704
|
function handlePtyPartial(text: string): void {
|
|
17705
|
+
// #2044 outbound secret scrub. The PTY-tail partial is the assistant's
|
|
17706
|
+
// reply text extracted from Claude Code's TUI as it renders — an agent
|
|
17707
|
+
// that echoes a secret shows it here (in the live draft-preview stream)
|
|
17708
|
+
// BEFORE the reply tool ever fires, and this path forwards the text
|
|
17709
|
+
// straight to Telegram with no other redaction. Mask at the gateway
|
|
17710
|
+
// boundary through the SAME reply-path redactor so the draft stream, its
|
|
17711
|
+
// dedup key, and lastPtyPreviewByChat all compare redacted-against-
|
|
17712
|
+
// redacted — the identical in-place-mutation invariant the answer stream
|
|
17713
|
+
// relies on. Idempotent: replaying a buffered (already-masked) partial
|
|
17714
|
+
// re-masks to itself.
|
|
17715
|
+
text = redactOutboundText(text, 'pty_preview')
|
|
16610
17716
|
// #1067: build the PtyHandlerState from a snapshot of currentTurn.
|
|
16611
17717
|
// The pty-partial-handler module keeps its own state-shape contract
|
|
16612
17718
|
// (currentSessionChatId / currentSessionThreadId) because it's
|
|
@@ -16641,6 +17747,13 @@ function handlePtyPartial(text: string): void {
|
|
|
16641
17747
|
}
|
|
16642
17748
|
|
|
16643
17749
|
function handlePtyActivity(text: string): void {
|
|
17750
|
+
// #2044 outbound secret scrub. PTY-activity is agent-derived free text
|
|
17751
|
+
// forwarded to Telegram via handleStreamReply — same leak class as the
|
|
17752
|
+
// draft preview above. Redact through the reply-path redactor before it
|
|
17753
|
+
// streams. (This handler is currently unwired in the gateway — the live
|
|
17754
|
+
// PTY path is handlePtyPartial — but the mask is kept here so the fix is
|
|
17755
|
+
// durable if the activity lane is ever re-armed.)
|
|
17756
|
+
text = redactOutboundText(text, 'pty_activity')
|
|
16644
17757
|
// #1067: snapshot at entry. handleStreamReply is async and runs in
|
|
16645
17758
|
// the background via void; the closure already captures `chatId` /
|
|
16646
17759
|
// `threadId` locals, so the supersession is correctly scoped.
|
|
@@ -16993,9 +18106,7 @@ function maybeEarlyAckReaction(ctx: Context, from: NonNullable<Context['from']>)
|
|
|
16993
18106
|
if (activeTurnStartedAt.has(statusKey(chatId, threadId))) return
|
|
16994
18107
|
const access = loadAccess()
|
|
16995
18108
|
if (!access.allowFrom.includes(String(from.id))) return
|
|
16996
|
-
void
|
|
16997
|
-
{ type: 'emoji', emoji: '👀' as ReactionTypeEmoji['emoji'] },
|
|
16998
|
-
]).catch(() => {})
|
|
18109
|
+
void sendReaction(chatId, msgId, '👀' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
16999
18110
|
// #2527: log the early-ack fire so operators can see how often the
|
|
17000
18111
|
// fast pre-coalesce DM path triggers vs. the controller path.
|
|
17001
18112
|
logStreamingEvent({ kind: 'early_ack_reaction', chatId, messageId: msgId, emoji: '👀' })
|
|
@@ -17004,7 +18115,9 @@ function maybeEarlyAckReaction(ctx: Context, from: NonNullable<Context['from']>)
|
|
|
17004
18115
|
// model text. No fake content — Telegram clients render this natively
|
|
17005
18116
|
// and it auto-expires after ~5s if not refreshed (the answer-lane
|
|
17006
18117
|
// first edit will land long before then under the new defaults).
|
|
17007
|
-
|
|
18118
|
+
// Through the shared emitter (#3084): a cold chat still lights up
|
|
18119
|
+
// instantly; a chat already pinged inside the floor doesn't pay twice.
|
|
18120
|
+
emitChatAction(chatId, null, 'typing')
|
|
17008
18121
|
}
|
|
17009
18122
|
|
|
17010
18123
|
/**
|
|
@@ -17063,6 +18176,16 @@ async function handleInbound(
|
|
|
17063
18176
|
// #2862 — operator is back; re-offer any approvals that timed out meanwhile.
|
|
17064
18177
|
maybePostMissedApprovalDigest('operator inbound')
|
|
17065
18178
|
|
|
18179
|
+
// #3026 — first inbound from a DM after boot: clear any stale STACKED pins
|
|
18180
|
+
// the boot getChat() probe can't see (DMs skip the probe AND getChat only
|
|
18181
|
+
// exposes the newest pin). unpin-all once per DM chat per boot, then re-pin
|
|
18182
|
+
// live tracked cards. once-guarded (dedups with the boot sweep); no-op for
|
|
18183
|
+
// groups and until the startup mutex is won. Fire-and-forget.
|
|
18184
|
+
{
|
|
18185
|
+
const inboundChatId = ctx.chat?.id
|
|
18186
|
+
if (inboundChatId != null) void dmPinSweeper.sweep(String(inboundChatId))
|
|
18187
|
+
}
|
|
18188
|
+
|
|
17066
18189
|
// Capture wall-clock receive time for inbound_ack metric (#203).
|
|
17067
18190
|
// Must be after gate() so early-exit paths (drop/pair) don't skew the delta.
|
|
17068
18191
|
//
|
|
@@ -17224,9 +18347,7 @@ async function handleInbound(
|
|
|
17224
18347
|
)
|
|
17225
18348
|
if (inFlight) {
|
|
17226
18349
|
if (msgId != null) {
|
|
17227
|
-
void
|
|
17228
|
-
{ type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
|
|
17229
|
-
]).catch(() => {})
|
|
18350
|
+
void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
17230
18351
|
}
|
|
17231
18352
|
await executeHaltNow('stop-keyword')
|
|
17232
18353
|
}
|
|
@@ -17271,9 +18392,7 @@ async function handleInbound(
|
|
|
17271
18392
|
`in_flight=${toolFlightTracker.inFlightCount()}\n`,
|
|
17272
18393
|
)
|
|
17273
18394
|
if (msgId != null) {
|
|
17274
|
-
void
|
|
17275
|
-
{ type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
|
|
17276
|
-
]).catch(() => {})
|
|
18395
|
+
void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
17277
18396
|
}
|
|
17278
18397
|
if (interrupt.emptyBody) {
|
|
17279
18398
|
// #3020: empty `!` is a pure halt (no replacement body) — same shared
|
|
@@ -17407,9 +18526,7 @@ async function handleInbound(
|
|
|
17407
18526
|
})
|
|
17408
18527
|
if (msgId != null) {
|
|
17409
18528
|
const emoji = behavior === 'allow' ? '✅' : '❌'
|
|
17410
|
-
void
|
|
17411
|
-
{ type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
|
|
17412
|
-
]).catch(() => {})
|
|
18529
|
+
void sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
17413
18530
|
}
|
|
17414
18531
|
return
|
|
17415
18532
|
}
|
|
@@ -17465,7 +18582,7 @@ async function handleInbound(
|
|
|
17465
18582
|
)
|
|
17466
18583
|
}
|
|
17467
18584
|
// Redact the OAuth code paste from chat history (#488).
|
|
17468
|
-
redactAuthCodeMessage(
|
|
18585
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
17469
18586
|
return
|
|
17470
18587
|
}
|
|
17471
18588
|
// Stale — drop the pending entry but let the message fall through
|
|
@@ -17474,6 +18591,101 @@ async function handleInbound(
|
|
|
17474
18591
|
pendingAuthAddFlows.delete(interceptKey)
|
|
17475
18592
|
}
|
|
17476
18593
|
|
|
18594
|
+
// Loopback OAuth relay paste-back intercept (issue #2582) — sibling to
|
|
18595
|
+
// the /auth add intercept above. When a Google/Microsoft loopback relay is
|
|
18596
|
+
// pending for this chat and the operator pastes their `127.0.0.1:<port>`
|
|
18597
|
+
// redirect URL, validate `state` and hand the code to the waiting CLI
|
|
18598
|
+
// listener. The LLM never sees the code (same hygiene rationale as above).
|
|
18599
|
+
// Consume-gate is deliberately narrow (PR #3100 review finding 1): only a
|
|
18600
|
+
// message that actually parses as a loopback redirect — carrying a code,
|
|
18601
|
+
// or a provider `error` param — is consumed and deleted. Unrelated chatter
|
|
18602
|
+
// that merely mentions localhost/127.0.0.1 flows through untouched.
|
|
18603
|
+
const pendingLoop = pendingLoopbackFlows.get(interceptKey)
|
|
18604
|
+
if (pendingLoop && shouldConsumeLoopbackPaste(text)) {
|
|
18605
|
+
const elapsed = Date.now() - pendingLoop.startedAt
|
|
18606
|
+
if (elapsed < REAUTH_INTERCEPT_TTL_MS) {
|
|
18607
|
+
if (pendingLoop.submitting) {
|
|
18608
|
+
// A submit is already in flight (double-paste race). Don't call
|
|
18609
|
+
// submitLoopbackRedirect — it would answer a non-retryable "already
|
|
18610
|
+
// completed" and we'd delete the entry out from under the first
|
|
18611
|
+
// submit. Benign ack; still redact (the paste carries a live code).
|
|
18612
|
+
await switchroomReply(
|
|
18613
|
+
ctx,
|
|
18614
|
+
'_Still finishing the previous paste — one moment._',
|
|
18615
|
+
{ html: true },
|
|
18616
|
+
)
|
|
18617
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18618
|
+
return
|
|
18619
|
+
}
|
|
18620
|
+
const result = await submitLoopbackRedirect(pendingLoop, text.trim())
|
|
18621
|
+
if (result.ok) {
|
|
18622
|
+
pendingLoopbackFlows.delete(interceptKey)
|
|
18623
|
+
await switchroomReply(
|
|
18624
|
+
ctx,
|
|
18625
|
+
`✓ ${pendingLoop.provider === 'google' ? 'Google' : 'Microsoft'} account ` +
|
|
18626
|
+
`\`${escapeHtmlForTg(pendingLoop.email)}\` registered with the auth-broker.`,
|
|
18627
|
+
{ html: true },
|
|
18628
|
+
)
|
|
18629
|
+
// Redact the pasted redirect (carries the OAuth code) from history.
|
|
18630
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18631
|
+
return
|
|
18632
|
+
}
|
|
18633
|
+
if (result.retryable) {
|
|
18634
|
+
// Keep the flow pending so the operator can paste again.
|
|
18635
|
+
await switchroomReply(
|
|
18636
|
+
ctx,
|
|
18637
|
+
`**Paste not accepted:** ${escapeHtmlForTg(result.reason)}\n` +
|
|
18638
|
+
`Re-open the consent URL, approve, and paste the full ` +
|
|
18639
|
+
`\`127.0.0.1\` URL from your address bar. \`/auth ${pendingLoop.provider} cancel\` to abort.`,
|
|
18640
|
+
{ html: true },
|
|
18641
|
+
)
|
|
18642
|
+
// Redact even a rejected paste — it may still carry a live code.
|
|
18643
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18644
|
+
return
|
|
18645
|
+
}
|
|
18646
|
+
// Non-retryable — the flow is spent. Kill the CLI child before
|
|
18647
|
+
// dropping the entry (re-review finding, PR #3100): on the attempts-
|
|
18648
|
+
// exhausted path the child is still alive with a bound 127.0.0.1
|
|
18649
|
+
// listener and nothing else would ever reap it. cancelLoopbackFlow is
|
|
18650
|
+
// idempotent — safe on the already-exited / timed-out paths too.
|
|
18651
|
+
cancelLoopbackFlow(pendingLoop)
|
|
18652
|
+
pendingLoopbackFlows.delete(interceptKey)
|
|
18653
|
+
await switchroomReply(
|
|
18654
|
+
ctx,
|
|
18655
|
+
`**/auth ${pendingLoop.provider} add failed:** ${escapeHtmlForTg(result.reason)}`,
|
|
18656
|
+
{ html: true },
|
|
18657
|
+
)
|
|
18658
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18659
|
+
return
|
|
18660
|
+
}
|
|
18661
|
+
// Stale — the intercept window has closed. Kill the child and drop the
|
|
18662
|
+
// entry, then fall through to the fail-safe below. We deliberately do NOT
|
|
18663
|
+
// let the paste reach the agent: the pasted `code` may still be live
|
|
18664
|
+
// (security audit #3084, F3), so the fail-safe redacts it.
|
|
18665
|
+
cancelLoopbackFlow(pendingLoop)
|
|
18666
|
+
pendingLoopbackFlows.delete(interceptKey)
|
|
18667
|
+
}
|
|
18668
|
+
|
|
18669
|
+
// Fail-safe redaction (security audit #3084, F2/F3). A message that looks
|
|
18670
|
+
// like a loopback OAuth redirect/code — even one too malformed to parse
|
|
18671
|
+
// cleanly, or one that arrived just after the intercept TTL closed, or one
|
|
18672
|
+
// with no active flow at all — must NEVER reach the (prompt-injectable)
|
|
18673
|
+
// agent session or linger unredacted in chat while carrying a possibly-live
|
|
18674
|
+
// credential. shouldConsumeLoopbackPaste is narrow (requires a loopback host
|
|
18675
|
+
// reference AND a code/error param), so ordinary chatter mentioning
|
|
18676
|
+
// localhost flows through untouched. Redact and drop rather than forward.
|
|
18677
|
+
if (shouldConsumeLoopbackPaste(text)) {
|
|
18678
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18679
|
+
await switchroomReply(
|
|
18680
|
+
ctx,
|
|
18681
|
+
'_That looked like an OAuth redirect/code, so I removed it from chat and did not forward it. ' +
|
|
18682
|
+
'If a Google/Microsoft account add is in progress, re-run the add command and paste the fresh ' +
|
|
18683
|
+
'`127.0.0.1` URL — the previous code may have expired._',
|
|
18684
|
+
{ html: true },
|
|
18685
|
+
)
|
|
18686
|
+
return
|
|
18687
|
+
}
|
|
18688
|
+
|
|
17477
18689
|
// Auth-code intercept
|
|
17478
18690
|
const pendingReauth = pendingReauthFlows.get(interceptKey)
|
|
17479
18691
|
if (pendingReauth && looksLikeAuthCode(text)) {
|
|
@@ -17498,7 +18710,7 @@ async function handleInbound(
|
|
|
17498
18710
|
// Single-use code so a third party can't replay it after exchange,
|
|
17499
18711
|
// but plaintext OAuth tokens in chat history are still poor
|
|
17500
18712
|
// hygiene. The helper handles delete + 🔑 reaction silently.
|
|
17501
|
-
redactAuthCodeMessage(
|
|
18713
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
17502
18714
|
return
|
|
17503
18715
|
}
|
|
17504
18716
|
pendingReauthFlows.delete(interceptKey)
|
|
@@ -17707,11 +18919,8 @@ async function handleInbound(
|
|
|
17707
18919
|
// Typing indicator in the ORIGINATING topic — on a supergroup-topic inbound,
|
|
17708
18920
|
// an un-threaded sendChatAction shows "typing" in General, not the topic the
|
|
17709
18921
|
// user is in. messageThreadId is the inbound's thread (undefined in a DM).
|
|
17710
|
-
|
|
17711
|
-
|
|
17712
|
-
'typing',
|
|
17713
|
-
messageThreadId != null ? { message_thread_id: messageThreadId } : {},
|
|
17714
|
-
).catch(() => {})
|
|
18922
|
+
// Floor-gated through the shared emitter (#3084).
|
|
18923
|
+
emitChatAction(chat_id, messageThreadId ?? null, 'typing')
|
|
17715
18924
|
|
|
17716
18925
|
// Parse explicit prefixes first. `/steer ` / `/s ` opts IN to steering;
|
|
17717
18926
|
// `/queue ` / `/q ` are legacy aliases that opt in to the new default (queued).
|
|
@@ -17964,12 +19173,12 @@ async function handleInbound(
|
|
|
17964
19173
|
if (isSteering) {
|
|
17965
19174
|
// Explicit steer: mark with 🤝 on the inbound message; leave the
|
|
17966
19175
|
// existing StatusReactionController running for the in-flight turn.
|
|
17967
|
-
void
|
|
19176
|
+
void sendReaction(chat_id, msgId, '🤝').catch(() => {})
|
|
17968
19177
|
} else if (priorTurnInFlight) {
|
|
17969
19178
|
// Queued mid-turn message (new default): don't touch the existing
|
|
17970
19179
|
// controller; just ack the inbound message with 👀 so the user
|
|
17971
19180
|
// knows we received it, without disrupting the in-flight reaction.
|
|
17972
|
-
void
|
|
19181
|
+
void sendReaction(chat_id, msgId, '👀').catch(() => {})
|
|
17973
19182
|
// #203: time-to-ack metric — measure gateway-receive → ack-post delta.
|
|
17974
19183
|
logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
|
|
17975
19184
|
} else {
|
|
@@ -18012,9 +19221,7 @@ async function handleInbound(
|
|
|
18012
19221
|
// msgId here and use it as the reaction-session token in log events.
|
|
18013
19222
|
const ctrlTurnToken = `${chat_id}:${msgId}`
|
|
18014
19223
|
const ctrl = new StatusReactionController(async (emoji) => {
|
|
18015
|
-
await
|
|
18016
|
-
{ type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
|
|
18017
|
-
])
|
|
19224
|
+
await sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji'])
|
|
18018
19225
|
// #203: every status-reaction transition is a user-visible signal.
|
|
18019
19226
|
signalTracker.noteSignal(key, Date.now())
|
|
18020
19227
|
}, allowedReactions, {
|
|
@@ -18097,9 +19304,7 @@ async function handleInbound(
|
|
|
18097
19304
|
}
|
|
18098
19305
|
}
|
|
18099
19306
|
} else if (access.ackReaction) {
|
|
18100
|
-
void
|
|
18101
|
-
{ type: 'emoji', emoji: access.ackReaction as ReactionTypeEmoji['emoji'] },
|
|
18102
|
-
]).catch(() => {})
|
|
19307
|
+
void sendReaction(chat_id, msgId, access.ackReaction as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
18103
19308
|
// #203: time-to-ack metric for the custom-ack-reaction path.
|
|
18104
19309
|
logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
|
|
18105
19310
|
}
|
|
@@ -18792,6 +19997,17 @@ function escapeHtmlForTg(text: string): string {
|
|
|
18792
19997
|
return text.replace(/([\\`*_~=\[\]|])/g, '\\$1')
|
|
18793
19998
|
}
|
|
18794
19999
|
|
|
20000
|
+
// #789 — button-choice-confirmation ("✅ You chose: X") annotation state.
|
|
20001
|
+
//
|
|
20002
|
+
// Two once-per-process warning dedupe sets keyed by agent slug: one for the
|
|
20003
|
+
// parseMode gate (annotation only supports the default 'html' parse mode),
|
|
20004
|
+
// one for the single_use mismatch (a re-tappable keyboard can never be
|
|
20005
|
+
// annotated, so a confirmation request on it silently no-ops). The decision
|
|
20006
|
+
// + rendered text are pure functions in inline-keyboard-callbacks.ts
|
|
20007
|
+
// (resolveTapAnnotation) so they can be unit-tested against the exact payload.
|
|
20008
|
+
const buttonConfirmParseModeWarned = new Set<string>()
|
|
20009
|
+
const buttonConfirmSingleUseWarned = new Set<string>()
|
|
20010
|
+
|
|
18795
20011
|
// Wrap CLI/command output in a fenced code block (content is literal there).
|
|
18796
20012
|
function preBlock(text: string): string {
|
|
18797
20013
|
return '```\n' + text.replace(/```/g, '```') + '\n```'
|
|
@@ -19431,7 +20647,7 @@ async function sweepBeforeSelfRestart(): Promise<void> {
|
|
|
19431
20647
|
try {
|
|
19432
20648
|
await sweepActiveReactions(
|
|
19433
20649
|
agentDir,
|
|
19434
|
-
(chatId, messageId) =>
|
|
20650
|
+
(chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
|
|
19435
20651
|
{ log: (msg) => process.stderr.write(`telegram gateway: pre-restart reaction sweep — ${msg}\n`) },
|
|
19436
20652
|
)
|
|
19437
20653
|
} catch (err) {
|
|
@@ -20127,6 +21343,30 @@ async function buildAgentMetadata(agentName: string): Promise<AgentMetadata> {
|
|
|
20127
21343
|
auth: authSummary,
|
|
20128
21344
|
audit: buildAgentAudit(agentName),
|
|
20129
21345
|
live: await buildLiveProbeRows(agentName),
|
|
21346
|
+
sendGate: buildSendGateStatus(),
|
|
21347
|
+
}
|
|
21348
|
+
}
|
|
21349
|
+
|
|
21350
|
+
/**
|
|
21351
|
+
* Build the `/status` send-gate block (#3084 PR 3, part3-design §6). Returns
|
|
21352
|
+
* `undefined` when the gate flag is OFF so `/status` renders exactly as it did
|
|
21353
|
+
* before the gate existed. Queued / shed totals come from the live counters;
|
|
21354
|
+
* open flood windows are read from the persisted sibling file (already pruned).
|
|
21355
|
+
*/
|
|
21356
|
+
function buildSendGateStatus(): AgentMetadata['sendGate'] {
|
|
21357
|
+
const s = sendGate.stats()
|
|
21358
|
+
if (!s.enabled) return undefined
|
|
21359
|
+
const openWindows = readFloodWindows(FLOOD_WINDOWS_PATH, Date.now()).map((w) => ({
|
|
21360
|
+
scopeKey: w.scopeKey,
|
|
21361
|
+
untilTs: w.untilTs,
|
|
21362
|
+
}))
|
|
21363
|
+
return {
|
|
21364
|
+
queued: s.global.queued,
|
|
21365
|
+
shed: s.global.shed,
|
|
21366
|
+
expired: s.global.expired,
|
|
21367
|
+
failedFast: s.global.failedFast,
|
|
21368
|
+
dropped: s.global.dropped,
|
|
21369
|
+
openWindows,
|
|
20130
21370
|
}
|
|
20131
21371
|
}
|
|
20132
21372
|
|
|
@@ -21447,15 +22687,25 @@ bot.command('update', async ctx => {
|
|
|
21447
22687
|
const skipImages = passthrough.includes('--skip-images')
|
|
21448
22688
|
const rebuild = passthrough.includes('--rebuild')
|
|
21449
22689
|
const updateRequestId = hostdRequestId('gw-update')
|
|
21450
|
-
|
|
21451
|
-
|
|
21452
|
-
|
|
21453
|
-
|
|
21454
|
-
|
|
21455
|
-
|
|
21456
|
-
|
|
21457
|
-
|
|
21458
|
-
|
|
22690
|
+
// #1841 — forward the cached operator passphrase as the 2nd factor when
|
|
22691
|
+
// hostd requires operator-attest on update_apply. No-op when the vault
|
|
22692
|
+
// is locked / no passphrase is cached (feature-off posture unchanged).
|
|
22693
|
+
const updatePassphrase = vaultPassphraseCache.get(chatId)?.passphrase
|
|
22694
|
+
const hostdResp = await tryHostdDispatch(
|
|
22695
|
+
getMyAgentName(),
|
|
22696
|
+
withOperatorAttestation(
|
|
22697
|
+
{
|
|
22698
|
+
v: 1,
|
|
22699
|
+
op: 'update_apply',
|
|
22700
|
+
request_id: updateRequestId,
|
|
22701
|
+
args: {
|
|
22702
|
+
...(skipImages ? { skip_images: true } : {}),
|
|
22703
|
+
...(rebuild ? { rebuild: true } : {}),
|
|
22704
|
+
},
|
|
22705
|
+
},
|
|
22706
|
+
updatePassphrase,
|
|
22707
|
+
),
|
|
22708
|
+
)
|
|
21459
22709
|
if (hostdResp === 'not-configured') {
|
|
21460
22710
|
warnLegacySpawnIfHostdDisabled('update_apply')
|
|
21461
22711
|
spawnSwitchroomDetached(
|
|
@@ -21705,6 +22955,7 @@ async function handlePermissionSlash(ctx: Context, behavior: 'allow' | 'deny'):
|
|
|
21705
22955
|
})
|
|
21706
22956
|
pendingPermissions.delete(request_id)
|
|
21707
22957
|
permCardStore.remove(request_id)
|
|
22958
|
+
reconcileBlockedApprovals()
|
|
21708
22959
|
process.stderr.write(
|
|
21709
22960
|
`[telegram gateway] slash-${behavior} request_id=${request_id} tool=${details.tool_name} by=${senderId}\n`,
|
|
21710
22961
|
)
|
|
@@ -23008,6 +24259,84 @@ bot.command("auth", async ctx => {
|
|
|
23008
24259
|
return
|
|
23009
24260
|
}
|
|
23010
24261
|
|
|
24262
|
+
// `/auth google add|cancel` and `/auth microsoft add|cancel` — the
|
|
24263
|
+
// Telegram-native OAuth loopback relay (issue #2582). Gateway-routed for the
|
|
24264
|
+
// same reason as `/auth add`: they drive a child-process listener lifecycle
|
|
24265
|
+
// the broker client can't model. Admin-gated identically.
|
|
24266
|
+
if (parsed.kind === 'provider-add' || parsed.kind === 'provider-cancel') {
|
|
24267
|
+
if (!isAuthAdmin({ isAdmin })) {
|
|
24268
|
+
await switchroomReply(
|
|
24269
|
+
ctx,
|
|
24270
|
+
`**Not authorized.** \`/auth ${parsed.provider}\` is admin-only.\n` +
|
|
24271
|
+
`Set \`admin: true\` on this agent in switchroom.yaml to unlock.`,
|
|
24272
|
+
{ html: true },
|
|
24273
|
+
)
|
|
24274
|
+
return
|
|
24275
|
+
}
|
|
24276
|
+
const loopKey = chatKey(chatId, ctx.message?.message_thread_id ?? null) as string
|
|
24277
|
+
if (parsed.kind === 'provider-cancel') {
|
|
24278
|
+
const existing = pendingLoopbackFlows.get(loopKey)
|
|
24279
|
+
if (!existing) {
|
|
24280
|
+
await switchroomReply(ctx, `_No pending \`/auth ${parsed.provider} add\` flow in this chat._`, { html: true })
|
|
24281
|
+
return
|
|
24282
|
+
}
|
|
24283
|
+
cancelLoopbackFlow(existing)
|
|
24284
|
+
pendingLoopbackFlows.delete(loopKey)
|
|
24285
|
+
await switchroomReply(ctx, 'Cancelled.', { html: true })
|
|
24286
|
+
return
|
|
24287
|
+
}
|
|
24288
|
+
// parsed.kind === 'provider-add'
|
|
24289
|
+
if (pendingLoopbackFlows.has(loopKey)) {
|
|
24290
|
+
await switchroomReply(
|
|
24291
|
+
ctx,
|
|
24292
|
+
`_An \`/auth ${parsed.provider} add\` flow is already in progress for this chat. ` +
|
|
24293
|
+
`Finish the paste, or send \`/auth ${parsed.provider} cancel\` to abort._`,
|
|
24294
|
+
{ html: true },
|
|
24295
|
+
)
|
|
24296
|
+
return
|
|
24297
|
+
}
|
|
24298
|
+
try {
|
|
24299
|
+
const { consentUrl, state, port, child } = await startLoopbackFlow(
|
|
24300
|
+
parsed.provider,
|
|
24301
|
+
parsed.email,
|
|
24302
|
+
{ replace: parsed.replace, write: parsed.write, orgMode: parsed.orgMode },
|
|
24303
|
+
)
|
|
24304
|
+
const newFlow = {
|
|
24305
|
+
provider: parsed.provider,
|
|
24306
|
+
email: parsed.email,
|
|
24307
|
+
state,
|
|
24308
|
+
port,
|
|
24309
|
+
consentUrl,
|
|
24310
|
+
child,
|
|
24311
|
+
startedAt: Date.now(),
|
|
24312
|
+
submitting: false,
|
|
24313
|
+
attempts: 0,
|
|
24314
|
+
}
|
|
24315
|
+
// Record child exit on the flow so a pre-paste crash fails fast
|
|
24316
|
+
// instead of hanging out the completion timeout (PR #3100 finding 4).
|
|
24317
|
+
trackFlowExit(newFlow)
|
|
24318
|
+
pendingLoopbackFlows.set(loopKey, newFlow)
|
|
24319
|
+
const providerName = parsed.provider === 'google' ? 'Google' : 'Microsoft'
|
|
24320
|
+
await switchroomReply(
|
|
24321
|
+
ctx,
|
|
24322
|
+
`**Adding ${providerName} account** \`${escapeHtmlForTg(parsed.email)}\`\n\n` +
|
|
24323
|
+
`1. Open this URL on your phone and approve:\n${consentUrl}\n\n` +
|
|
24324
|
+
`2. The redirect to \`127.0.0.1\` will fail to load — that's expected.\n` +
|
|
24325
|
+
`3. Copy the **full URL** from your browser's address bar (it holds ` +
|
|
24326
|
+
`\`?code=...&state=...\`) and paste it back here.\n\n` +
|
|
24327
|
+
`Send \`/auth ${parsed.provider} cancel\` to abort.`,
|
|
24328
|
+
{ html: true },
|
|
24329
|
+
)
|
|
24330
|
+
} catch (err) {
|
|
24331
|
+
await switchroomReply(
|
|
24332
|
+
ctx,
|
|
24333
|
+
`**/auth ${parsed.provider} add failed:** ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`,
|
|
24334
|
+
{ html: true },
|
|
24335
|
+
)
|
|
24336
|
+
}
|
|
24337
|
+
return
|
|
24338
|
+
}
|
|
24339
|
+
|
|
23011
24340
|
const client = await getAuthBrokerClient(currentAgent)
|
|
23012
24341
|
if (!client) {
|
|
23013
24342
|
await switchroomReply(ctx, "**/auth unavailable:** auth-broker client is not loaded (post-RFC-H rewire in progress?).", { html: true })
|
|
@@ -23201,6 +24530,9 @@ const callbackQueryHandlers = createCallbackQueryHandlers({
|
|
|
23201
24530
|
getAdminOnlyKeys: () => ADMIN_ONLY_KEYS,
|
|
23202
24531
|
vaultKeyRegex: VAULT_KEY_REGEX,
|
|
23203
24532
|
mentalModelProposeTtlMs: MENTAL_MODEL_PROPOSE_TTL_MS,
|
|
24533
|
+
// #2975 Stage 1 — loud-failure funnel for a rate-window retry that also
|
|
24534
|
+
// failed (cooldown + record + broadcast in one place).
|
|
24535
|
+
emitOperatorEvent: emitGatewayOperatorEvent,
|
|
23204
24536
|
})
|
|
23205
24537
|
const {
|
|
23206
24538
|
handleVaultRecentDenialCallback,
|
|
@@ -23691,7 +25023,18 @@ bot.command('usage', async ctx => {
|
|
|
23691
25023
|
// /auth snapshot does. switchroomReply routes through the rich path
|
|
23692
25024
|
// (replyWithRichMessage), which accepts reply_markup. Build a grammy
|
|
23693
25025
|
// InlineKeyboard so the markup type matches switchroomReply's contract.
|
|
23694
|
-
|
|
25026
|
+
// Defense-in-depth (mirrors the operator-private `/auth` treatment,
|
|
25027
|
+
// gateway.ts ~24036): outside a private chat, strip the
|
|
25028
|
+
// `auth:use:<label>` "Switch fleet" rows so the fleet-wide account-
|
|
25029
|
+
// swap button is never even offered in a group/forum. The dispatch-
|
|
25030
|
+
// site allowFrom gate is the load-bearing control; this just avoids
|
|
25031
|
+
// dangling a privileged button in front of non-operators.
|
|
25032
|
+
let kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
|
|
25033
|
+
if (ctx.chat?.type !== 'private') {
|
|
25034
|
+
kbRows = kbRows.filter(
|
|
25035
|
+
(row) => !row.some((b) => b.callbackData?.startsWith('auth:use:')),
|
|
25036
|
+
)
|
|
25037
|
+
}
|
|
23695
25038
|
const keyboard = new InlineKeyboard()
|
|
23696
25039
|
kbRows.forEach((row, ri) => {
|
|
23697
25040
|
if (ri > 0) keyboard.row()
|
|
@@ -23819,7 +25162,19 @@ bot.on('callback_query:data', async ctx => {
|
|
|
23819
25162
|
// Auth dashboard buttons (`auth:<verb>:<agent>[:<slot>]`). Route
|
|
23820
25163
|
// through a dedicated handler that maps each action onto the
|
|
23821
25164
|
// existing CLI invocations plus dashboard refresh.
|
|
25165
|
+
//
|
|
25166
|
+
// Strict allowFrom gate like every other mutating callback family
|
|
25167
|
+
// (`eff:`/`apv:`/`cfg:`/`cn:`/`mdl:`). Its absence was a vulnerability:
|
|
25168
|
+
// `auth:use:<label>` drives `client.setActive(label)`, a fleet-wide
|
|
25169
|
+
// OAuth account swap — on an admin forum/supergroup agent with an empty
|
|
25170
|
+
// group allowFrom, any member could tap it and swap the active account.
|
|
23822
25171
|
if (data.startsWith('auth:')) {
|
|
25172
|
+
const access = loadAccess()
|
|
25173
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
25174
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
25175
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' })
|
|
25176
|
+
return
|
|
25177
|
+
}
|
|
23823
25178
|
await handleAuthDashboardCallback(ctx)
|
|
23824
25179
|
return
|
|
23825
25180
|
}
|
|
@@ -24634,8 +25989,74 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24634
25989
|
// opts out via `single_use: false`. With no stashed meta (e.g.
|
|
24635
25990
|
// gateway restarted between send and tap) the default fires too,
|
|
24636
25991
|
// which is the desired UX.
|
|
24637
|
-
const
|
|
24638
|
-
|
|
25992
|
+
const singleUse = metaForMessage == null || keyboardIsSingleUse(metaForMessage)
|
|
25993
|
+
|
|
25994
|
+
// Source message body text — photos/stickers/etc. carry no `text`.
|
|
25995
|
+
const cbMsg = ctx.callbackQuery?.message
|
|
25996
|
+
const sourceText = cbMsg && 'text' in cbMsg && typeof cbMsg.text === 'string'
|
|
25997
|
+
? cbMsg.text
|
|
25998
|
+
: undefined
|
|
25999
|
+
|
|
26000
|
+
// #789: pure decision for the "✅ You chose: X" body annotation. Per-message
|
|
26001
|
+
// override (the tapped button's inline_keyboard_confirm) wins over the agent
|
|
26002
|
+
// default; fires only on single-use keyboards with body text + a label and
|
|
26003
|
+
// the default 'html' parse mode.
|
|
26004
|
+
const annotation = resolveTapAnnotation({
|
|
26005
|
+
...(tapMeta?.inline_keyboard_confirm != null
|
|
26006
|
+
? { perMessageOverride: tapMeta.inline_keyboard_confirm }
|
|
26007
|
+
: {}),
|
|
26008
|
+
singleUse,
|
|
26009
|
+
...(access.button_choice_confirmation != null
|
|
26010
|
+
? { config: access.button_choice_confirmation }
|
|
26011
|
+
: {}),
|
|
26012
|
+
parseMode: access.parseMode ?? 'html',
|
|
26013
|
+
...(sourceText != null ? { sourceText } : {}),
|
|
26014
|
+
...(buttonText != null ? { label: buttonText } : {}),
|
|
26015
|
+
// escapeLabel deliberately omitted → defaults to the real HTML-entity
|
|
26016
|
+
// escaper (escapeHtmlEntities). The GFM-markdown escaper
|
|
26017
|
+
// (escapeHtmlForTg, #2669) is WRONG here: it doesn't escape &/</> (a
|
|
26018
|
+
// label containing them would 400 the HTML editMessageText) and it
|
|
26019
|
+
// garbles text under HTML parse mode (`Do_it` → `Do\_it`).
|
|
26020
|
+
})
|
|
26021
|
+
|
|
26022
|
+
const warnKey = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
26023
|
+
// Round-1 minor finding: a confirmation requested on a single_use:false
|
|
26024
|
+
// (re-tappable) keyboard can never fire. Warn once per agent-process.
|
|
26025
|
+
if (annotation.warnSingleUseMismatch && !buttonConfirmSingleUseWarned.has(warnKey)) {
|
|
26026
|
+
buttonConfirmSingleUseWarned.add(warnKey)
|
|
26027
|
+
process.stderr.write(
|
|
26028
|
+
`telegram gateway: button_choice_confirmation skipped — inline_keyboard_confirm requested on a single_use:false (re-tappable) keyboard; annotation only fires on single-use keyboards (#789)\n`,
|
|
26029
|
+
)
|
|
26030
|
+
}
|
|
26031
|
+
// Parse-mode gate (Blocker 1): annotation is emitted with HTML. Warn once
|
|
26032
|
+
// per agent-process when a non-html parseMode suppresses it.
|
|
26033
|
+
if (annotation.warnParseMode && !buttonConfirmParseModeWarned.has(warnKey)) {
|
|
26034
|
+
buttonConfirmParseModeWarned.add(warnKey)
|
|
26035
|
+
process.stderr.write(
|
|
26036
|
+
`telegram gateway: button_choice_confirmation skipped — agent parseMode=${access.parseMode ?? 'html'}, only 'html' supported (#789)\n`,
|
|
26037
|
+
)
|
|
26038
|
+
}
|
|
26039
|
+
|
|
26040
|
+
if (annotation.annotate && cbMessageId != null) {
|
|
26041
|
+
// Single API call (Blocker 2): editMessageText accepts reply_markup, so
|
|
26042
|
+
// we annotate the body AND strip the keyboard in one edit. If the edit
|
|
26043
|
+
// rejects (400 on an HTML edge case, over-long body, too-old message…),
|
|
26044
|
+
// applyTapAnnotationEdit falls back to a keyboard-only strip so
|
|
26045
|
+
// single-use protection still holds — the meta is deleted below either
|
|
26046
|
+
// way, so a swallowed failure must not leave the keyboard live.
|
|
26047
|
+
await applyTapAnnotationEdit(
|
|
26048
|
+
{
|
|
26049
|
+
editMessageText: (text, other) => ctx.editMessageText(text, other),
|
|
26050
|
+
editMessageReplyMarkup: other => ctx.editMessageReplyMarkup(other),
|
|
26051
|
+
},
|
|
26052
|
+
annotation.text as string,
|
|
26053
|
+
)
|
|
26054
|
+
if (metaForMessage != null) {
|
|
26055
|
+
agentButtonMeta.delete(`${cbChatId}:${cbMessageId}`)
|
|
26056
|
+
}
|
|
26057
|
+
} else if (singleUse && cbMessageId != null) {
|
|
26058
|
+
// No annotation (disabled, non-single-use, non-html parseMode, or no
|
|
26059
|
+
// body text) — preserve the historical keyboard-only strip.
|
|
24639
26060
|
await ctx.editMessageReplyMarkup({
|
|
24640
26061
|
reply_markup: { inline_keyboard: [] },
|
|
24641
26062
|
}).catch(() => {})
|
|
@@ -24714,6 +26135,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
24714
26135
|
|
|
24715
26136
|
pendingPermissions.delete(request_id)
|
|
24716
26137
|
permCardStore.remove(request_id)
|
|
26138
|
+
reconcileBlockedApprovals()
|
|
24717
26139
|
|
|
24718
26140
|
// (2) Dispatch the in-flight permission verdict IMMEDIATELY — before
|
|
24719
26141
|
// any host round-trip — so the turn never blocks on persistence.
|
|
@@ -25009,6 +26431,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
25009
26431
|
const grantAgent = selfAgentName()
|
|
25010
26432
|
pendingPermissions.delete(request_id)
|
|
25011
26433
|
permCardStore.remove(request_id)
|
|
26434
|
+
reconcileBlockedApprovals()
|
|
25012
26435
|
if (timeBox && grantAgent) {
|
|
25013
26436
|
recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
|
|
25014
26437
|
// Write-through so the window survives a gateway restart (#2863). Absolute
|
|
@@ -25453,9 +26876,7 @@ async function handleAckOnly(
|
|
|
25453
26876
|
const chat_id = String(ctx.chat!.id)
|
|
25454
26877
|
const msgId = ctx.message?.message_id
|
|
25455
26878
|
if (msgId != null) {
|
|
25456
|
-
void
|
|
25457
|
-
{ type: 'emoji', emoji: (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji'] },
|
|
25458
|
-
]).catch(() => {})
|
|
26879
|
+
void sendReaction(chat_id, msgId, (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
25459
26880
|
}
|
|
25460
26881
|
const prefix = opts.warn ? 'WARN ' : ''
|
|
25461
26882
|
process.stderr.write(`telegram gateway: ${prefix}inbound ${kind} ack-only chat_id=${chat_id} from=${ctx.from?.id ?? '?'}\n`)
|
|
@@ -25488,9 +26909,7 @@ async function handleRefusal(
|
|
|
25488
26909
|
const msgId = ctx.message?.message_id
|
|
25489
26910
|
const messageThreadId = ctx.message?.message_thread_id
|
|
25490
26911
|
if (msgId != null) {
|
|
25491
|
-
void
|
|
25492
|
-
{ type: 'emoji', emoji: '🚫' as ReactionTypeEmoji['emoji'] },
|
|
25493
|
-
]).catch(() => {})
|
|
26912
|
+
void sendReaction(chat_id, msgId, '🚫' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
25494
26913
|
}
|
|
25495
26914
|
// #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND so a
|
|
25496
26915
|
// deleted topic doesn't crash the refusal handler.
|
|
@@ -26049,16 +27468,22 @@ function flushReactionBatch(batch: ReactionBatch): void {
|
|
|
26049
27468
|
text,
|
|
26050
27469
|
meta,
|
|
26051
27470
|
}
|
|
26052
|
-
|
|
26053
|
-
|
|
27471
|
+
// #2094 finding 3 — route the reaction flush through the SAME #1556
|
|
27472
|
+
// decideInboundDelivery gate every other synthetic inbound uses (via
|
|
27473
|
+
// deliverResumeSyntheticOrBuffer), instead of a raw ipcServer.sendToAgent.
|
|
27474
|
+
// A reaction that lands WHILE a turn is in flight was previously fired
|
|
27475
|
+
// mid-turn — the bridge typed it into the CLI composer where it stranded
|
|
27476
|
+
// by the turn-completion race (the #1556 composer wedge). Now a mid-turn
|
|
27477
|
+
// reaction buffers-until-idle (the turn-complete hook + idle-drain timer
|
|
27478
|
+
// flush pendingInboundBuffer the instant claude goes idle, landing cleanly
|
|
27479
|
+
// as a fresh turn); an idle reaction delivers now, buffering only on a
|
|
27480
|
+
// genuine bridge-offline miss — the #1150 buffer-on-failure guarantee,
|
|
27481
|
+
// preserved inside the helper.
|
|
27482
|
+
const delivered = deliverResumeSyntheticOrBuffer(agentName, inbound)
|
|
26054
27483
|
process.stderr.write(
|
|
26055
27484
|
`telegram gateway: reactions.dispatch agent=${agentName} chat=${batch.chatId} ` +
|
|
26056
27485
|
`count=${batch.reactions.length} batched=${batch.batched} delivered=${delivered}\n`,
|
|
26057
27486
|
)
|
|
26058
|
-
// #1150: buffer-on-failure for reaction-triggered wake-ups too.
|
|
26059
|
-
if (!delivered) {
|
|
26060
|
-
pendingInboundBuffer.push(agentName, inbound)
|
|
26061
|
-
}
|
|
26062
27487
|
}
|
|
26063
27488
|
|
|
26064
27489
|
// ─── Inbound message_reaction handler ────────────────────────────────────
|
|
@@ -26506,6 +27931,10 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
26506
27931
|
// Now finish the cleanup the drain didn't touch.
|
|
26507
27932
|
inboundCoalescer.reset()
|
|
26508
27933
|
pendingReauthFlows.clear()
|
|
27934
|
+
// Kill any in-flight loopback relay CLI children so they don't outlive the
|
|
27935
|
+
// gateway with a bound 127.0.0.1 listener (issue #2582).
|
|
27936
|
+
for (const [, v] of pendingLoopbackFlows) cancelLoopbackFlow(v)
|
|
27937
|
+
pendingLoopbackFlows.clear()
|
|
26509
27938
|
pendingVaultOps.clear()
|
|
26510
27939
|
pendingPermissions.clear()
|
|
26511
27940
|
permissionTimeoutSignatures.clear()
|
|
@@ -26539,7 +27968,7 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
|
|
|
26539
27968
|
if (startupAgentDir != null) {
|
|
26540
27969
|
void sweepActiveReactions(
|
|
26541
27970
|
startupAgentDir,
|
|
26542
|
-
(chatId, messageId) =>
|
|
27971
|
+
(chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
|
|
26543
27972
|
{ log: (msg) => process.stderr.write(`telegram gateway: startup reaction sweep — ${msg}\n`) },
|
|
26544
27973
|
)
|
|
26545
27974
|
}
|
|
@@ -26949,6 +28378,22 @@ void (async () => {
|
|
|
26949
28378
|
)
|
|
26950
28379
|
}
|
|
26951
28380
|
|
|
28381
|
+
// #3084 follow-up — drop a blocked-approval record orphaned by a restart
|
|
28382
|
+
// mid-hold. `pendingPermissions` is in-memory and empty on a fresh
|
|
28383
|
+
// process, so this reconciles the surface to "nothing held". Without it,
|
|
28384
|
+
// a gateway restart during a flood ban would leave a record on disk that
|
|
28385
|
+
// nothing ever clears, and the dashboard would show a permanently
|
|
28386
|
+
// blocked agent. The ASK itself survives: the bridge re-sends unresolved
|
|
28387
|
+
// permission requests on IPC reconnect, which re-raises them (and
|
|
28388
|
+
// re-marks them held if the channel is still shut).
|
|
28389
|
+
try {
|
|
28390
|
+
reconcileBlockedApprovals()
|
|
28391
|
+
} catch (err) {
|
|
28392
|
+
process.stderr.write(
|
|
28393
|
+
`telegram gateway: blocked-approval boot reconcile failed: ${(err as Error).message}\n`,
|
|
28394
|
+
)
|
|
28395
|
+
}
|
|
28396
|
+
|
|
26952
28397
|
// Boot-time pin sweep
|
|
26953
28398
|
try {
|
|
26954
28399
|
const bootAccess = loadAccess()
|
|
@@ -27382,7 +28827,8 @@ void (async () => {
|
|
|
27382
28827
|
richMessage(text),
|
|
27383
28828
|
sendOpts as Parameters<typeof lockedBot.api.sendRichMessage>[2],
|
|
27384
28829
|
),
|
|
27385
|
-
|
|
28830
|
+
// #3084 PR 2: worker-feed card CREATION (handback) is USEFUL.
|
|
28831
|
+
{ chat_id: cid, verb: 'worker-feed', priorityClass: 'useful' },
|
|
27386
28832
|
)
|
|
27387
28833
|
return sent as { message_id: number }
|
|
27388
28834
|
},
|
|
@@ -27395,7 +28841,15 @@ void (async () => {
|
|
|
27395
28841
|
richMessage(text),
|
|
27396
28842
|
editOpts as Parameters<typeof lockedBot.api.editMessageText>[3],
|
|
27397
28843
|
),
|
|
27398
|
-
|
|
28844
|
+
// Worker-feed EDITs are COSMETIC — pass messageId/editPayload
|
|
28845
|
+
// so the per-message floor + coalescing + no-op skip engage.
|
|
28846
|
+
{
|
|
28847
|
+
chat_id: cid,
|
|
28848
|
+
verb: 'worker-feed',
|
|
28849
|
+
priorityClass: 'cosmetic',
|
|
28850
|
+
messageId: mid,
|
|
28851
|
+
editPayload: text,
|
|
28852
|
+
},
|
|
27399
28853
|
),
|
|
27400
28854
|
},
|
|
27401
28855
|
log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
@@ -27423,30 +28877,41 @@ void (async () => {
|
|
|
27423
28877
|
// Best-effort: a registry read failure (e.g. no worktree dir
|
|
27424
28878
|
// on an agent that never claims one) must not affect the
|
|
27425
28879
|
// primary agentCwd watch.
|
|
27426
|
-
|
|
27427
|
-
|
|
27428
|
-
|
|
27429
|
-
|
|
27430
|
-
|
|
27431
|
-
|
|
27432
|
-
|
|
27433
|
-
|
|
27434
|
-
|
|
27435
|
-
|
|
27436
|
-
|
|
27437
|
-
|
|
27438
|
-
|
|
27439
|
-
|
|
27440
|
-
|
|
27441
|
-
|
|
27442
|
-
|
|
27443
|
-
|
|
27444
|
-
|
|
27445
|
-
|
|
27446
|
-
|
|
27447
|
-
|
|
27448
|
-
|
|
27449
|
-
|
|
28880
|
+
// On every ~1s rescan tick the provider does BOTH:
|
|
28881
|
+
// 1. advances the heartbeat of every worktree THIS agent owns
|
|
28882
|
+
// — the F1/H3 production driver that keeps `touchHeartbeat`
|
|
28883
|
+
// alive (it had ZERO callers, so every claim read "stale"
|
|
28884
|
+
// 10 min after creation and the reaper's staleness
|
|
28885
|
+
// guarantee collapsed); throttled to ≤1 write / 2 min per
|
|
28886
|
+
// record, and
|
|
28887
|
+
// 2. returns the fail-CLOSED set of owned worktree cwds for the
|
|
28888
|
+
// watcher to also watch (#1116 / Gap-2 ownership predicate).
|
|
28889
|
+
// Extracted to telegram-plugin/worktree-watch-cwds.ts as
|
|
28890
|
+
// `makeWorktreeWatchProvider` so the wiring — specifically that
|
|
28891
|
+
// the provider ACTUALLY drives heartbeats, not just cwds — is
|
|
28892
|
+
// under direct unit test (see the provider behaviour test in
|
|
28893
|
+
// telegram-plugin/tests/worktree-watch-cwds.test.ts). Fully
|
|
28894
|
+
// best-effort: it never throws out of the provider.
|
|
28895
|
+
extraWatchCwdsProvider: makeWorktreeWatchProvider({
|
|
28896
|
+
self: process.env.SWITCHROOM_AGENT_NAME,
|
|
28897
|
+
// Durable, non-env identity fallback (#1116 / #2893): when
|
|
28898
|
+
// SWITCHROOM_AGENT_NAME is somehow unset, derive this agent's
|
|
28899
|
+
// own identity from its own directory so worktree ownership
|
|
28900
|
+
// still resolves (env is only the fast path). `watcherAgentDir`
|
|
28901
|
+
// is guaranteed non-null in this branch (the whole watcher is
|
|
28902
|
+
// gated on it above). Kill-switch
|
|
28903
|
+
// SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the pre-fix
|
|
28904
|
+
// env-only behaviour. One `agentDir` governs BOTH the heartbeat
|
|
28905
|
+
// refresh and the cwd derivation.
|
|
28906
|
+
agentDir:
|
|
28907
|
+
process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
|
|
28908
|
+
? undefined
|
|
28909
|
+
: watcherAgentDir,
|
|
28910
|
+
listRecords: listWorktreeRecords,
|
|
28911
|
+
touchHeartbeat: touchWorktreeHeartbeat,
|
|
28912
|
+
log: (msg) =>
|
|
28913
|
+
process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
28914
|
+
}),
|
|
27450
28915
|
// Bug 0 fix: previously omitted, leaving the watcher unable to
|
|
27451
28916
|
// write liveness/stall/turn_end updates to the registry DB.
|
|
27452
28917
|
// Liveness writes are now persisted across the gateway lifetime.
|