switchroom 0.18.12 → 0.18.14
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 +57 -9
- package/dist/auth-broker/index.js +174 -72
- package/dist/cli/autoaccept-poll.js +23 -0
- package/dist/cli/drive-write-pretool.mjs +24 -1
- package/dist/cli/foreground-hog-pretool.mjs +264 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +9 -2
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +986 -3131
- package/dist/host-control/main.js +216 -2863
- package/dist/vault/approvals/kernel-server.js +67 -1
- package/dist/vault/broker/server.js +98 -45
- package/package.json +1 -1
- package/profiles/coding/CLAUDE.md.hbs +2 -0
- package/profiles/default/CLAUDE.md.hbs +2 -0
- package/skills/switchroom-architecture/telegram.md +0 -1
- package/telegram-plugin/auth-snapshot-format.ts +37 -5
- package/telegram-plugin/auto-fallback-fleet.ts +29 -1
- package/telegram-plugin/bridge/bridge.ts +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +51 -3
- package/telegram-plugin/dist/gateway/gateway.js +1251 -2368
- package/telegram-plugin/dist/server.js +67 -3
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +21 -2
- package/telegram-plugin/gateway/auth-broker-client.ts +1 -0
- package/telegram-plugin/gateway/auth-command.ts +14 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +12 -0
- package/telegram-plugin/gateway/forward-origin.ts +235 -0
- package/telegram-plugin/gateway/gateway.ts +445 -83
- package/telegram-plugin/gateway/throttle-tier-wiring.ts +268 -0
- package/telegram-plugin/history.ts +106 -6
- package/telegram-plugin/inline-keyboard-callbacks.ts +94 -0
- package/telegram-plugin/model-unavailable.ts +61 -13
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/render/rich-render.ts +40 -32
- 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 +131 -0
- package/telegram-plugin/send-gate.test.ts +25 -6
- package/telegram-plugin/send-gate.ts +82 -8
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/stream-controller.ts +3 -2
- package/telegram-plugin/subagent-watcher.ts +71 -16
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +36 -5
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +72 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/forward-origin.test.ts +309 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/history.test.ts +272 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +164 -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/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +6 -4
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/render/rich-render.test.ts +41 -22
- 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-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +5 -3
- package/telegram-plugin/tests/status-accent.test.ts +5 -3
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +20 -20
- package/telegram-plugin/tests/stream-reply-handler.test.ts +5 -2
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/throttle-tier-wiring.test.ts +290 -0
- package/telegram-plugin/tests/throttle-tier.test.ts +278 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/throttle-tier.ts +226 -0
- package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +8 -7
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- 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,
|
|
@@ -93,6 +94,13 @@ import {
|
|
|
93
94
|
buildExtraAttachmentMeta,
|
|
94
95
|
resolveCoalesceMaxAttachments,
|
|
95
96
|
} from './coalesce-attachments.js'
|
|
97
|
+
import {
|
|
98
|
+
parseForwardOrigin,
|
|
99
|
+
dedupeForwardOrigins,
|
|
100
|
+
buildForwardOriginMeta,
|
|
101
|
+
forwardOriginDateIso,
|
|
102
|
+
type ForwardOriginInfo,
|
|
103
|
+
} from './forward-origin.js'
|
|
96
104
|
import { StatusReactionController } from '../status-reactions.js'
|
|
97
105
|
import { DeferredDoneReactions } from '../reaction-defer.js'
|
|
98
106
|
import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
|
|
@@ -309,7 +317,14 @@ import { recordOperatorEvent } from '../operator-events-history.js'
|
|
|
309
317
|
import {
|
|
310
318
|
formatModelUnavailableCard,
|
|
311
319
|
resolveModelUnavailableFromOperatorEvent,
|
|
320
|
+
type ModelUnavailableDetection,
|
|
312
321
|
} from '../model-unavailable.js'
|
|
322
|
+
import {
|
|
323
|
+
decideThrottleTier,
|
|
324
|
+
isAccountScopedThrottle,
|
|
325
|
+
throttleRetryInPlaceMaxMs,
|
|
326
|
+
} from '../throttle-tier.js'
|
|
327
|
+
import { createThrottleTierRunner } from './throttle-tier-wiring.js'
|
|
313
328
|
import { runFleetAutoFallback, renderFallbackFailureNotice, evaluateFallbackFailureNotice, evaluateAllBlockedNotice, type FallbackFailureNoticeState, type FallbackAllBlockedNoticeState } from '../auto-fallback-fleet.js'
|
|
314
329
|
import { startRestartWatchdog } from './restart-watchdog.js'
|
|
315
330
|
import { validateStringArray } from './access-validator.js'
|
|
@@ -340,6 +355,7 @@ import {
|
|
|
340
355
|
} from '../telegram-button-constraints.js'
|
|
341
356
|
import {
|
|
342
357
|
wrapAgentCallbacks,
|
|
358
|
+
redactAgentKeyboard,
|
|
343
359
|
parseAgentCallback,
|
|
344
360
|
extractAgentButtonMeta,
|
|
345
361
|
keyboardIsSingleUse,
|
|
@@ -691,8 +707,8 @@ import {
|
|
|
691
707
|
startSubagentWatcher,
|
|
692
708
|
type SubagentWatcherHandle,
|
|
693
709
|
} from '../subagent-watcher.js'
|
|
694
|
-
import { listRecords as listWorktreeRecords } from '../../src/worktree/registry.js'
|
|
695
|
-
import {
|
|
710
|
+
import { listRecords as listWorktreeRecords, touchHeartbeat as touchWorktreeHeartbeat } from '../../src/worktree/registry.js'
|
|
711
|
+
import { makeWorktreeWatchProvider } from '../worktree-watch-cwds.js'
|
|
696
712
|
import {
|
|
697
713
|
startBootCard,
|
|
698
714
|
resolvePersonaName,
|
|
@@ -5466,9 +5482,10 @@ const recordFloodWindow = makeFloodWindowRecorder(FLOOD_WINDOWS_PATH)
|
|
|
5466
5482
|
// edit floor + no-op-edit skip + PRIORITY SHEDDING + DEGRADED MODE). Wrapped
|
|
5467
5483
|
// HERE at the robustApiCall layer so every Bot API call routed through the
|
|
5468
5484
|
// standard retry policy also transits one scheduler (no call site can bypass
|
|
5469
|
-
// it).
|
|
5470
|
-
// the gate
|
|
5471
|
-
// before. Composes with #3094's pre-call flood gate
|
|
5485
|
+
// it). ON BY DEFAULT (escape hatch): only when SWITCHROOM_TELEGRAM_SEND_GATE is
|
|
5486
|
+
// explicitly set to 0/false/off/no is the gate a pure passthrough and the retry
|
|
5487
|
+
// policy behaves exactly as before. Composes with #3094's pre-call flood gate
|
|
5488
|
+
// and #3097's non-essential
|
|
5472
5489
|
// drop policy — those decide whether a call happens at all; this paces the
|
|
5473
5490
|
// calls that do.
|
|
5474
5491
|
//
|
|
@@ -5527,6 +5544,65 @@ const swallowingApiCall = createSwallowingRetryApiCall(
|
|
|
5527
5544
|
(line) => process.stderr.write(line),
|
|
5528
5545
|
)
|
|
5529
5546
|
|
|
5547
|
+
/**
|
|
5548
|
+
* The ONE seam every `setMessageReaction` in this gateway goes through (#3155).
|
|
5549
|
+
*
|
|
5550
|
+
* Reactions are cosmetic outbound traffic. Before this seam, ~13 raw
|
|
5551
|
+
* `bot.api.setMessageReaction(...)` / `lockedBot.api.setMessageReaction(...)`
|
|
5552
|
+
* call sites fired reactions OUTSIDE the send gate AND the flood circuit
|
|
5553
|
+
* breaker, so:
|
|
5554
|
+
*
|
|
5555
|
+
* (a) reaction spam was never PACED/SHED by the gate when it's on — a busy
|
|
5556
|
+
* turn's status-reaction churn added straight onto the per-bot flood
|
|
5557
|
+
* budget the REPLIES need; and
|
|
5558
|
+
* (b) a 429 from a reaction was INVISIBLE to the breaker — the flood window
|
|
5559
|
+
* is only learned through the retry module's `onFloodWait` hook, which a
|
|
5560
|
+
* raw `bot.api` call never reaches. Reactions were a residual flood
|
|
5561
|
+
* vector even with the gate ON.
|
|
5562
|
+
*
|
|
5563
|
+
* Routing through `robustApiCall` (= chat-lock → send-gate → retry/breaker,
|
|
5564
|
+
* the SAME path every other outbound call takes) with
|
|
5565
|
+
* `priorityClass: 'cosmetic'` fixes both: the gate paces/sheds reactions under
|
|
5566
|
+
* pressure, and a 429 runs `onFloodWait` so the window is recorded. Callers
|
|
5567
|
+
* keep their own fire-and-forget / `.catch(() => {})` / `await` semantics —
|
|
5568
|
+
* this returns the promise and does NOT swallow.
|
|
5569
|
+
*/
|
|
5570
|
+
const gatedSetMessageReaction = (
|
|
5571
|
+
chatId: string,
|
|
5572
|
+
messageId: number,
|
|
5573
|
+
reaction: ReactionTypeEmoji[],
|
|
5574
|
+
): Promise<unknown> =>
|
|
5575
|
+
robustApiCall(() => lockedBot.api.setMessageReaction(chatId, messageId, reaction), {
|
|
5576
|
+
chat_id: chatId,
|
|
5577
|
+
verb: 'set-message-reaction',
|
|
5578
|
+
priorityClass: 'cosmetic',
|
|
5579
|
+
})
|
|
5580
|
+
|
|
5581
|
+
/** React with a single emoji through the gate (cosmetic). Wraps {@link gatedSetMessageReaction}. */
|
|
5582
|
+
const sendReaction = (
|
|
5583
|
+
chatId: string,
|
|
5584
|
+
messageId: number,
|
|
5585
|
+
emoji: ReactionTypeEmoji['emoji'],
|
|
5586
|
+
): Promise<unknown> => gatedSetMessageReaction(chatId, messageId, [{ type: 'emoji', emoji }])
|
|
5587
|
+
|
|
5588
|
+
/**
|
|
5589
|
+
* Bot-API surface handed to `redactAuthCodeMessage` (#488) so its 🔑 reaction
|
|
5590
|
+
* routes through the send gate + flood breaker like every other reaction
|
|
5591
|
+
* (#3155). The OAuth-code DELETE stays raw/best-effort exactly as before — its
|
|
5592
|
+
* own `.then(onOk, onErr)` logging (the "token may still be visible" breadcrumb)
|
|
5593
|
+
* must keep firing, which a swallowing wrapper would suppress.
|
|
5594
|
+
*/
|
|
5595
|
+
const redactAuthCodeApi = {
|
|
5596
|
+
deleteMessage: (chatId: string, messageId: number) =>
|
|
5597
|
+
// allow-raw-bot-api: auth-code redact delete stays raw/best-effort (behavior unchanged, #488); #3155 gates only the reaction.
|
|
5598
|
+
bot.api.deleteMessage(chatId, messageId),
|
|
5599
|
+
setMessageReaction: (
|
|
5600
|
+
chatId: string,
|
|
5601
|
+
messageId: number,
|
|
5602
|
+
reaction: Array<{ type: 'emoji'; emoji: string }>,
|
|
5603
|
+
) => gatedSetMessageReaction(chatId, messageId, reaction as ReactionTypeEmoji[]),
|
|
5604
|
+
}
|
|
5605
|
+
|
|
5530
5606
|
/**
|
|
5531
5607
|
* The wrapper for NON-ESSENTIAL sends that must NEVER retry (#3084).
|
|
5532
5608
|
*
|
|
@@ -7525,6 +7601,14 @@ type CoalescePayload = {
|
|
|
7525
7601
|
attachment?: AttachmentMeta
|
|
7526
7602
|
// Set only by `merge`: the 2nd..Nth attachments folded into this turn.
|
|
7527
7603
|
extraAttachments?: CoalesceAttachment[]
|
|
7604
|
+
// Forwarded-message origin of THIS entry (parsed at enqueue time from
|
|
7605
|
+
// message.forward_origin). merged.ctx is the LAST entry's ctx, so a
|
|
7606
|
+
// coalesced burst would otherwise lose the earlier entries' origins —
|
|
7607
|
+
// each entry carries its own.
|
|
7608
|
+
forwardOrigin?: ForwardOriginInfo
|
|
7609
|
+
// Set only by `merge`: the distinct origins of the whole burst, arrival
|
|
7610
|
+
// order, deduped (a single-origin album collapses to one entry).
|
|
7611
|
+
forwardOrigins?: ForwardOriginInfo[]
|
|
7528
7612
|
}
|
|
7529
7613
|
|
|
7530
7614
|
// Count of attachment-bearing entries currently buffered per coalesce key.
|
|
@@ -7557,6 +7641,11 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7557
7641
|
(e) => e.downloadImage != null || e.attachment != null,
|
|
7558
7642
|
coalesceMaxAttachments(),
|
|
7559
7643
|
)
|
|
7644
|
+
// Distinct forwarded-message origins across the burst, arrival order.
|
|
7645
|
+
// A forwarded album (one origin, N parts) collapses to a single entry
|
|
7646
|
+
// so the attrs are emitted once; a burst forwarded from two different
|
|
7647
|
+
// senders keeps both (numbered forwarded_from_2 siblings downstream).
|
|
7648
|
+
const forwardOrigins = dedupeForwardOrigins(entries.map((e) => e.forwardOrigin))
|
|
7560
7649
|
return {
|
|
7561
7650
|
// Drop empty texts (e.g. caption-less album parts) so the join doesn't
|
|
7562
7651
|
// emit blank lines between attachments.
|
|
@@ -7567,6 +7656,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7567
7656
|
extraAttachments: extras.length > 0
|
|
7568
7657
|
? extras.map((e) => ({ downloadImage: e.downloadImage, attachment: e.attachment }))
|
|
7569
7658
|
: undefined,
|
|
7659
|
+
forwardOrigins: forwardOrigins.length > 0 ? forwardOrigins : undefined,
|
|
7570
7660
|
}
|
|
7571
7661
|
},
|
|
7572
7662
|
onFlush: (key, merged) => {
|
|
@@ -7577,6 +7667,7 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7577
7667
|
merged.downloadImage,
|
|
7578
7668
|
merged.attachment,
|
|
7579
7669
|
merged.extraAttachments,
|
|
7670
|
+
merged.forwardOrigins,
|
|
7580
7671
|
)
|
|
7581
7672
|
},
|
|
7582
7673
|
})
|
|
@@ -7597,6 +7688,72 @@ const inboundCoalescer = createInboundCoalescer<CoalescePayload>({
|
|
|
7597
7688
|
function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
7598
7689
|
const { agent, kind } = event
|
|
7599
7690
|
|
|
7691
|
+
// ── 429 throttle tier (operator spec: "retry in place under 5 min, else
|
|
7692
|
+
// mark + failover, honest reset messaging") ────────────────────────────
|
|
7693
|
+
// A terminal TRANSIENT ACCOUNT-scoped 429 — kind `rate-limited` carrying
|
|
7694
|
+
// wording that affirms the account's own rate limit ("would exceed your
|
|
7695
|
+
// account's rate limit") — is decided BEFORE the per-kind cooldown gate:
|
|
7696
|
+
// every hit must reach the broker's mark-throttled escalation counter, and
|
|
7697
|
+
// the >threshold failover ACTION must fire, even when the user-facing card
|
|
7698
|
+
// is cooldown-suppressed (a calm 529 card two minutes earlier arms that
|
|
7699
|
+
// cooldown). Server-side transients (529 / "server is temporarily…") fall
|
|
7700
|
+
// through to the existing calm rate-limited card unchanged — an
|
|
7701
|
+
// account-scoped throttle would be the wrong action for a server-wide
|
|
7702
|
+
// condition.
|
|
7703
|
+
let throttleEscalation: ModelUnavailableDetection | null = null
|
|
7704
|
+
let escalationFired = false
|
|
7705
|
+
if (kind === 'rate-limited' && isAccountScopedThrottle(event.detail)) {
|
|
7706
|
+
const throttleDecision = decideThrottleTier({
|
|
7707
|
+
detail: event.detail,
|
|
7708
|
+
now: Date.now(),
|
|
7709
|
+
thresholdMs: throttleRetryInPlaceMaxMs(),
|
|
7710
|
+
})
|
|
7711
|
+
if (throttleDecision.action === 'throttle') {
|
|
7712
|
+
// Reset is near (≤ threshold) or unparseable (60s default): DO NOT
|
|
7713
|
+
// fail over. Record throttled_until broker-side, post ONE lightweight
|
|
7714
|
+
// notice (per-account + fleet-wide dedup), nudge a retry after the
|
|
7715
|
+
// reset. The runner owns the sequencing (throttle-tier-wiring.ts).
|
|
7716
|
+
process.stderr.write(
|
|
7717
|
+
`telegram gateway: throttle-tier staying-put agent=${agent} ` +
|
|
7718
|
+
`until=${new Date(throttleDecision.throttledUntilMs).toISOString()} ` +
|
|
7719
|
+
`parsedReset=${throttleDecision.resetParsed}\n`,
|
|
7720
|
+
)
|
|
7721
|
+
try {
|
|
7722
|
+
recordOperatorEvent(event)
|
|
7723
|
+
} catch { /* history is best-effort */ }
|
|
7724
|
+
void throttleTierRunner.fire(
|
|
7725
|
+
agent,
|
|
7726
|
+
throttleDecision.throttledUntilMs,
|
|
7727
|
+
throttleDecision.resetParsed,
|
|
7728
|
+
)
|
|
7729
|
+
return
|
|
7730
|
+
}
|
|
7731
|
+
if (throttleDecision.action === 'failover') {
|
|
7732
|
+
// Reset beyond the retry-in-place threshold: the account is benched
|
|
7733
|
+
// until then anyway, so escalate to the standard mark-exhausted +
|
|
7734
|
+
// fleet-failover machinery — honest 'rate_limited' wording, parsed
|
|
7735
|
+
// reset as the mark expiry. The ACTION fires HERE, before the
|
|
7736
|
+
// per-kind card cooldown below can swallow it (the fleetFallbackGate
|
|
7737
|
+
// provides the action-level dedup); only the card stays
|
|
7738
|
+
// cooldown-bounded. The 'rate-limit' trigger tells the dispatcher to
|
|
7739
|
+
// trust this terminal parsed-reset signal over the utilization probe
|
|
7740
|
+
// (a transient 429 negates the usage-limit reading, so utilization
|
|
7741
|
+
// typically looks healthy — the idempotency guard would self-cancel
|
|
7742
|
+
// the swap).
|
|
7743
|
+
const resetAt = new Date(throttleDecision.resetAtMs)
|
|
7744
|
+
throttleEscalation = { kind: 'rate_limited', resetAt, raw: event.detail }
|
|
7745
|
+
if (wouldFireFleetAutoFallback()) {
|
|
7746
|
+
escalationFired = true
|
|
7747
|
+
void fireFleetAutoFallback(
|
|
7748
|
+
agent,
|
|
7749
|
+
resolveExhaustUntil(resetAt.getTime()),
|
|
7750
|
+
resetAt,
|
|
7751
|
+
'rate-limit',
|
|
7752
|
+
)
|
|
7753
|
+
}
|
|
7754
|
+
}
|
|
7755
|
+
}
|
|
7756
|
+
|
|
7600
7757
|
if (!shouldEmitOperatorEvent(agent, kind)) {
|
|
7601
7758
|
process.stderr.write(
|
|
7602
7759
|
`telegram gateway: operator-event suppressed (cooldown) agent=${agent} kind=${kind}\n`,
|
|
@@ -7625,7 +7782,10 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7625
7782
|
// by the upstream classifier
|
|
7626
7783
|
// - detail string contains one of the model-unavailable text patterns
|
|
7627
7784
|
// (covers raw stderr that slipped past structured classification)
|
|
7628
|
-
|
|
7785
|
+
// Throttle-tier escalation (long-reset transient 429) takes precedence:
|
|
7786
|
+
// resolveModelUnavailableFromOperatorEvent deliberately returns null for a
|
|
7787
|
+
// bare rate-limited event, but this one is auto-fallback-eligible.
|
|
7788
|
+
const modelUnavailable = throttleEscalation ?? resolveModelUnavailableFromOperatorEvent(event)
|
|
7629
7789
|
let renderedText: string
|
|
7630
7790
|
let renderedKeyboard: ReturnType<typeof renderOperatorEvent>['keyboard'] | undefined
|
|
7631
7791
|
// #3031 PR 3 — when the card promises an in-flight auto-failover, record
|
|
@@ -7647,8 +7807,18 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7647
7807
|
// it just produces a self-cancelling "probed healthy / Stale event?"
|
|
7648
7808
|
// loop on every 529. Overload is handled by Claude Code's own
|
|
7649
7809
|
// internal retry, not by switching accounts.
|
|
7650
|
-
|
|
7651
|
-
|
|
7810
|
+
// 'rate_limited' is the throttle-tier escalation (transient 429 whose
|
|
7811
|
+
// reset exceeds the retry-in-place threshold) — same failover mechanics
|
|
7812
|
+
// as a quota wall, honest wording on the card. Its fire already happened
|
|
7813
|
+
// ABOVE the cooldown gate (escalationFired) so the action can't be
|
|
7814
|
+
// swallowed by the per-kind card cooldown; the card here only reports it.
|
|
7815
|
+
const isAutoKind =
|
|
7816
|
+
modelUnavailable.kind === 'quota_exhausted' ||
|
|
7817
|
+
modelUnavailable.kind === 'rate_limited'
|
|
7818
|
+
const willActuallyFire =
|
|
7819
|
+
throttleEscalation != null
|
|
7820
|
+
? escalationFired
|
|
7821
|
+
: isAutoKind && wouldFireFleetAutoFallback()
|
|
7652
7822
|
process.stderr.write(
|
|
7653
7823
|
`telegram gateway: operator-event suppressing-raw-stderr-for-model-unavailable agent=${agent} kind=${kind} detected=${modelUnavailable.kind} autoKind=${isAutoKind} willFire=${willActuallyFire}\n`,
|
|
7654
7824
|
)
|
|
@@ -7672,9 +7842,14 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
7672
7842
|
// Pre-fix this called fireFleetAutoFallback(agent) with no until, so a
|
|
7673
7843
|
// weekly wall that surfaced as a 429 got markExhausted's ~5h default and
|
|
7674
7844
|
// the broker re-mirrored the still-walled account onto the fleet after 5h.
|
|
7675
|
-
|
|
7845
|
+
// Throttle-tier escalations (throttleEscalation != null) already fired
|
|
7846
|
+
// above the cooldown gate — don't double-fire here.
|
|
7847
|
+
if (willActuallyFire && throttleEscalation == null) {
|
|
7676
7848
|
const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime())
|
|
7677
|
-
|
|
7849
|
+
// Thread the RAW parsed reset (distinct from the floor-applied untilMs)
|
|
7850
|
+
// so the fallback announcement can name it even when the live probe of
|
|
7851
|
+
// the old account fails — honest reset messaging on the enriched card.
|
|
7852
|
+
void fireFleetAutoFallback(agent, untilMs, modelUnavailable.resetAt)
|
|
7678
7853
|
}
|
|
7679
7854
|
} else {
|
|
7680
7855
|
try {
|
|
@@ -11312,7 +11487,13 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
11312
11487
|
(msg.resetAt == null ? ' (reset unparsed → +7d default)' : '') +
|
|
11313
11488
|
' — triggering fleet auto-fallback\n',
|
|
11314
11489
|
)
|
|
11315
|
-
void fireFleetAutoFallback(
|
|
11490
|
+
void fireFleetAutoFallback(
|
|
11491
|
+
msg.agentName,
|
|
11492
|
+
untilMs,
|
|
11493
|
+
// Enriched announcement: the sidecar-parsed reset (when present) keeps
|
|
11494
|
+
// the recovery line honest even when the old account's probe fails.
|
|
11495
|
+
msg.resetAt != null ? new Date(msg.resetAt) : undefined,
|
|
11496
|
+
)
|
|
11316
11497
|
},
|
|
11317
11498
|
|
|
11318
11499
|
// Issue #2971 — read-only wedge-watchdog probe: is there a live pending
|
|
@@ -11724,10 +11905,21 @@ async function executeSendChecklist(args: Record<string, unknown>): Promise<{ co
|
|
|
11724
11905
|
|
|
11725
11906
|
assertAllowedChat(chat_id)
|
|
11726
11907
|
|
|
11727
|
-
|
|
11728
|
-
|
|
11908
|
+
// #2044 outbound secret scrub. Checklist title + task strings are
|
|
11909
|
+
// agent-authored free text sent to Telegram, same class as the reply
|
|
11910
|
+
// `text` path and ask_user — route both through the SAME redactor before
|
|
11911
|
+
// send (via the pure, unit-tested redactChecklistFields helper) so an
|
|
11912
|
+
// echoed token / DATABASE_URL is masked.
|
|
11913
|
+
const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
|
|
11729
11914
|
title,
|
|
11730
11915
|
tasks,
|
|
11916
|
+
(t) => redactOutboundText(t, 'send_checklist'),
|
|
11917
|
+
)
|
|
11918
|
+
|
|
11919
|
+
const sent = await rawSendChecklist({
|
|
11920
|
+
chat_id,
|
|
11921
|
+
title: redactedTitle!,
|
|
11922
|
+
tasks: redactedTasks!,
|
|
11731
11923
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
11732
11924
|
...(replyTo != null ? { reply_to_message_id: replyTo } : {}),
|
|
11733
11925
|
...(protectContent ? { protect_content: true } : {}),
|
|
@@ -11809,7 +12001,19 @@ async function executeUpdateChecklist(args: Record<string, unknown>): Promise<{
|
|
|
11809
12001
|
|
|
11810
12002
|
assertAllowedChat(chat_id)
|
|
11811
12003
|
|
|
11812
|
-
|
|
12004
|
+
// #2044 outbound secret scrub. update_checklist forwards the same
|
|
12005
|
+
// agent-authored title + task text to Telegram as send_checklist, so it
|
|
12006
|
+
// shares the identical leak class — redact both through the reply-path
|
|
12007
|
+
// redactor (via the shared redactChecklistFields helper) before the edit
|
|
12008
|
+
// lands. title / tasks may be undefined here (partial patch); the helper
|
|
12009
|
+
// passes undefined through untouched.
|
|
12010
|
+
const { title: redactedTitle, tasks: redactedTasks } = redactChecklistFields(
|
|
12011
|
+
title,
|
|
12012
|
+
tasks,
|
|
12013
|
+
(t) => redactOutboundText(t, 'update_checklist'),
|
|
12014
|
+
)
|
|
12015
|
+
|
|
12016
|
+
await rawEditMessageChecklist({ chat_id, message_id, title: redactedTitle, tasks: redactedTasks })
|
|
11813
12017
|
|
|
11814
12018
|
process.stderr.write(`telegram gateway: update_checklist: updated chatId=${chat_id} messageId=${message_id}\n`)
|
|
11815
12019
|
return { content: [{ type: 'text', text: `checklist updated (id: ${message_id})` }] }
|
|
@@ -12537,8 +12741,18 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
12537
12741
|
.join('; ')
|
|
12538
12742
|
throw new Error(`inline_keyboard validation failed: ${summary}`)
|
|
12539
12743
|
}
|
|
12540
|
-
|
|
12541
|
-
|
|
12744
|
+
// #3148 fast-follow: mask any secret an agent put in a visible button
|
|
12745
|
+
// `text` label, its `ack_text` toast, or a `copy_text.text` clipboard
|
|
12746
|
+
// payload BEFORE the keyboard is sent — the same outbound scrub the reply
|
|
12747
|
+
// `text` body uses. `callback_data` (the routing key) is left exact.
|
|
12748
|
+
// Feeding BOTH the meta extraction and the callback wrap from the redacted
|
|
12749
|
+
// copy means the stashed toast, the tap echo (`button_text`), and the
|
|
12750
|
+
// "✅ You chose: <label>" annotation (#789) all read already-masked bytes.
|
|
12751
|
+
const redactedKeyboard = redactAgentKeyboard(rawKeyboard, (s) =>
|
|
12752
|
+
redactOutboundText(s, 'reply_inline_keyboard'),
|
|
12753
|
+
)
|
|
12754
|
+
replyButtonMeta = extractAgentButtonMeta(redactedKeyboard)
|
|
12755
|
+
replyMarkup = { inline_keyboard: wrapAgentCallbacks(redactedKeyboard) }
|
|
12542
12756
|
}
|
|
12543
12757
|
|
|
12544
12758
|
// on-demand voice: append a single '🔊 Listen' button that synthesizes the
|
|
@@ -13509,6 +13723,21 @@ async function executeAskUser(rawArgs: Record<string, unknown>): Promise<unknown
|
|
|
13509
13723
|
const args = validateAskUserArgs(rawArgs as unknown as AskUserArgs)
|
|
13510
13724
|
assertAllowedChat(args.chatId)
|
|
13511
13725
|
|
|
13726
|
+
// #2044 outbound secret scrub. The question text and each option/button
|
|
13727
|
+
// label are agent-authored free text sent to Telegram, exactly like the
|
|
13728
|
+
// reply `text` path — an agent that echoes a token or DATABASE_URL it just
|
|
13729
|
+
// read into a question or a button label would transmit it unmasked. Route
|
|
13730
|
+
// both through the SAME redactor as reply (via the pure, unit-tested
|
|
13731
|
+
// redactAskUserFields helper), assigning back onto the validated args so
|
|
13732
|
+
// every downstream consumer — the send, the timeout-edit re-render, and
|
|
13733
|
+
// the tap-echo of the chosen label stored in pendingAskUser — sees the
|
|
13734
|
+
// masked value.
|
|
13735
|
+
const scrubbed = redactAskUserFields(args.question, args.options, (t) =>
|
|
13736
|
+
redactOutboundText(t, 'ask_user'),
|
|
13737
|
+
)
|
|
13738
|
+
args.question = scrubbed.question
|
|
13739
|
+
args.options = scrubbed.options
|
|
13740
|
+
|
|
13512
13741
|
// Resolve thread + reply-to using the same auto-thread heuristic
|
|
13513
13742
|
// executeReply uses, so an agent that omits message_thread_id still
|
|
13514
13743
|
// routes into the right forum topic and quotes the user's last
|
|
@@ -14609,9 +14838,11 @@ async function executeReact(args: Record<string, unknown>): Promise<unknown> {
|
|
|
14609
14838
|
if (!args.message_id) throw new Error('react: message_id is required')
|
|
14610
14839
|
if (!args.emoji) throw new Error('react: emoji is required')
|
|
14611
14840
|
assertAllowedChat(String(args.chat_id ?? ''))
|
|
14612
|
-
await
|
|
14613
|
-
|
|
14614
|
-
|
|
14841
|
+
await sendReaction(
|
|
14842
|
+
String(args.chat_id ?? ''),
|
|
14843
|
+
Number(args.message_id),
|
|
14844
|
+
args.emoji as ReactionTypeEmoji['emoji'],
|
|
14845
|
+
)
|
|
14615
14846
|
return { content: [{ type: 'text', text: 'reacted' }] }
|
|
14616
14847
|
}
|
|
14617
14848
|
|
|
@@ -17590,6 +17821,17 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
17590
17821
|
|
|
17591
17822
|
// ─── PTY partial handler ─────────────────────────────────────────────────
|
|
17592
17823
|
function handlePtyPartial(text: string): void {
|
|
17824
|
+
// #2044 outbound secret scrub. The PTY-tail partial is the assistant's
|
|
17825
|
+
// reply text extracted from Claude Code's TUI as it renders — an agent
|
|
17826
|
+
// that echoes a secret shows it here (in the live draft-preview stream)
|
|
17827
|
+
// BEFORE the reply tool ever fires, and this path forwards the text
|
|
17828
|
+
// straight to Telegram with no other redaction. Mask at the gateway
|
|
17829
|
+
// boundary through the SAME reply-path redactor so the draft stream, its
|
|
17830
|
+
// dedup key, and lastPtyPreviewByChat all compare redacted-against-
|
|
17831
|
+
// redacted — the identical in-place-mutation invariant the answer stream
|
|
17832
|
+
// relies on. Idempotent: replaying a buffered (already-masked) partial
|
|
17833
|
+
// re-masks to itself.
|
|
17834
|
+
text = redactOutboundText(text, 'pty_preview')
|
|
17593
17835
|
// #1067: build the PtyHandlerState from a snapshot of currentTurn.
|
|
17594
17836
|
// The pty-partial-handler module keeps its own state-shape contract
|
|
17595
17837
|
// (currentSessionChatId / currentSessionThreadId) because it's
|
|
@@ -17624,6 +17866,13 @@ function handlePtyPartial(text: string): void {
|
|
|
17624
17866
|
}
|
|
17625
17867
|
|
|
17626
17868
|
function handlePtyActivity(text: string): void {
|
|
17869
|
+
// #2044 outbound secret scrub. PTY-activity is agent-derived free text
|
|
17870
|
+
// forwarded to Telegram via handleStreamReply — same leak class as the
|
|
17871
|
+
// draft preview above. Redact through the reply-path redactor before it
|
|
17872
|
+
// streams. (This handler is currently unwired in the gateway — the live
|
|
17873
|
+
// PTY path is handlePtyPartial — but the mask is kept here so the fix is
|
|
17874
|
+
// durable if the activity lane is ever re-armed.)
|
|
17875
|
+
text = redactOutboundText(text, 'pty_activity')
|
|
17627
17876
|
// #1067: snapshot at entry. handleStreamReply is async and runs in
|
|
17628
17877
|
// the background via void; the closure already captures `chatId` /
|
|
17629
17878
|
// `threadId` locals, so the supersession is correctly scoped.
|
|
@@ -17940,7 +18189,15 @@ async function handleInboundCoalesced(
|
|
|
17940
18189
|
ctx.message?.message_thread_id,
|
|
17941
18190
|
String(from.id),
|
|
17942
18191
|
)
|
|
17943
|
-
const result = inboundCoalescer.enqueue(key, {
|
|
18192
|
+
const result = inboundCoalescer.enqueue(key, {
|
|
18193
|
+
text,
|
|
18194
|
+
ctx,
|
|
18195
|
+
downloadImage,
|
|
18196
|
+
attachment,
|
|
18197
|
+
// Parsed HERE (not in merge) because merge only sees the last ctx —
|
|
18198
|
+
// each buffered entry keeps its own forwarded-message origin.
|
|
18199
|
+
forwardOrigin: parseForwardOrigin(ctx.message?.forward_origin),
|
|
18200
|
+
})
|
|
17944
18201
|
// Coalescing disabled (window <= 0): flush immediately, preserving any
|
|
17945
18202
|
// media this message carried.
|
|
17946
18203
|
if (result.bypass) return handleInbound(ctx, text, downloadImage, attachment)
|
|
@@ -17976,9 +18233,7 @@ function maybeEarlyAckReaction(ctx: Context, from: NonNullable<Context['from']>)
|
|
|
17976
18233
|
if (activeTurnStartedAt.has(statusKey(chatId, threadId))) return
|
|
17977
18234
|
const access = loadAccess()
|
|
17978
18235
|
if (!access.allowFrom.includes(String(from.id))) return
|
|
17979
|
-
void
|
|
17980
|
-
{ type: 'emoji', emoji: '👀' as ReactionTypeEmoji['emoji'] },
|
|
17981
|
-
]).catch(() => {})
|
|
18236
|
+
void sendReaction(chatId, msgId, '👀' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
17982
18237
|
// #2527: log the early-ack fire so operators can see how often the
|
|
17983
18238
|
// fast pre-coalesce DM path triggers vs. the controller path.
|
|
17984
18239
|
logStreamingEvent({ kind: 'early_ack_reaction', chatId, messageId: msgId, emoji: '👀' })
|
|
@@ -18021,6 +18276,11 @@ async function handleInbound(
|
|
|
18021
18276
|
// resolved (photos downloaded) and surfaced as numbered meta fields
|
|
18022
18277
|
// (image_path_2, attachment_file_id_2, …) alongside the primary.
|
|
18023
18278
|
extraAttachments?: CoalesceAttachment[],
|
|
18279
|
+
// Forwarded-message origins for a coalesced burst (deduped, arrival
|
|
18280
|
+
// order — see the coalescer's merge). Left undefined by the direct /
|
|
18281
|
+
// bypass call sites; handleInbound then parses THIS ctx's
|
|
18282
|
+
// forward_origin itself, so single-message forwards work on every path.
|
|
18283
|
+
coalescedForwardOrigins?: ForwardOriginInfo[],
|
|
18024
18284
|
): Promise<void> {
|
|
18025
18285
|
markIdleActivity() // any inbound resets the idle auto-clear timer + re-arms
|
|
18026
18286
|
const isTopicMessage = ctx.message?.is_topic_message ?? false
|
|
@@ -18219,9 +18479,7 @@ async function handleInbound(
|
|
|
18219
18479
|
)
|
|
18220
18480
|
if (inFlight) {
|
|
18221
18481
|
if (msgId != null) {
|
|
18222
|
-
void
|
|
18223
|
-
{ type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
|
|
18224
|
-
]).catch(() => {})
|
|
18482
|
+
void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
18225
18483
|
}
|
|
18226
18484
|
await executeHaltNow('stop-keyword')
|
|
18227
18485
|
}
|
|
@@ -18266,9 +18524,7 @@ async function handleInbound(
|
|
|
18266
18524
|
`in_flight=${toolFlightTracker.inFlightCount()}\n`,
|
|
18267
18525
|
)
|
|
18268
18526
|
if (msgId != null) {
|
|
18269
|
-
void
|
|
18270
|
-
{ type: 'emoji', emoji: '⚡' as ReactionTypeEmoji['emoji'] },
|
|
18271
|
-
]).catch(() => {})
|
|
18527
|
+
void sendReaction(chat_id, msgId, '⚡' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
18272
18528
|
}
|
|
18273
18529
|
if (interrupt.emptyBody) {
|
|
18274
18530
|
// #3020: empty `!` is a pure halt (no replacement body) — same shared
|
|
@@ -18402,9 +18658,7 @@ async function handleInbound(
|
|
|
18402
18658
|
})
|
|
18403
18659
|
if (msgId != null) {
|
|
18404
18660
|
const emoji = behavior === 'allow' ? '✅' : '❌'
|
|
18405
|
-
void
|
|
18406
|
-
{ type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
|
|
18407
|
-
]).catch(() => {})
|
|
18661
|
+
void sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
18408
18662
|
}
|
|
18409
18663
|
return
|
|
18410
18664
|
}
|
|
@@ -18460,7 +18714,7 @@ async function handleInbound(
|
|
|
18460
18714
|
)
|
|
18461
18715
|
}
|
|
18462
18716
|
// Redact the OAuth code paste from chat history (#488).
|
|
18463
|
-
redactAuthCodeMessage(
|
|
18717
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18464
18718
|
return
|
|
18465
18719
|
}
|
|
18466
18720
|
// Stale — drop the pending entry but let the message fall through
|
|
@@ -18492,7 +18746,7 @@ async function handleInbound(
|
|
|
18492
18746
|
'_Still finishing the previous paste — one moment._',
|
|
18493
18747
|
{ html: true },
|
|
18494
18748
|
)
|
|
18495
|
-
redactAuthCodeMessage(
|
|
18749
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18496
18750
|
return
|
|
18497
18751
|
}
|
|
18498
18752
|
const result = await submitLoopbackRedirect(pendingLoop, text.trim())
|
|
@@ -18505,7 +18759,7 @@ async function handleInbound(
|
|
|
18505
18759
|
{ html: true },
|
|
18506
18760
|
)
|
|
18507
18761
|
// Redact the pasted redirect (carries the OAuth code) from history.
|
|
18508
|
-
redactAuthCodeMessage(
|
|
18762
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18509
18763
|
return
|
|
18510
18764
|
}
|
|
18511
18765
|
if (result.retryable) {
|
|
@@ -18518,7 +18772,7 @@ async function handleInbound(
|
|
|
18518
18772
|
{ html: true },
|
|
18519
18773
|
)
|
|
18520
18774
|
// Redact even a rejected paste — it may still carry a live code.
|
|
18521
|
-
redactAuthCodeMessage(
|
|
18775
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18522
18776
|
return
|
|
18523
18777
|
}
|
|
18524
18778
|
// Non-retryable — the flow is spent. Kill the CLI child before
|
|
@@ -18533,7 +18787,7 @@ async function handleInbound(
|
|
|
18533
18787
|
`**/auth ${pendingLoop.provider} add failed:** ${escapeHtmlForTg(result.reason)}`,
|
|
18534
18788
|
{ html: true },
|
|
18535
18789
|
)
|
|
18536
|
-
redactAuthCodeMessage(
|
|
18790
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18537
18791
|
return
|
|
18538
18792
|
}
|
|
18539
18793
|
// Stale — the intercept window has closed. Kill the child and drop the
|
|
@@ -18553,7 +18807,7 @@ async function handleInbound(
|
|
|
18553
18807
|
// reference AND a code/error param), so ordinary chatter mentioning
|
|
18554
18808
|
// localhost flows through untouched. Redact and drop rather than forward.
|
|
18555
18809
|
if (shouldConsumeLoopbackPaste(text)) {
|
|
18556
|
-
redactAuthCodeMessage(
|
|
18810
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18557
18811
|
await switchroomReply(
|
|
18558
18812
|
ctx,
|
|
18559
18813
|
'_That looked like an OAuth redirect/code, so I removed it from chat and did not forward it. ' +
|
|
@@ -18588,7 +18842,7 @@ async function handleInbound(
|
|
|
18588
18842
|
// Single-use code so a third party can't replay it after exchange,
|
|
18589
18843
|
// but plaintext OAuth tokens in chat history are still poor
|
|
18590
18844
|
// hygiene. The helper handles delete + 🔑 reaction silently.
|
|
18591
|
-
redactAuthCodeMessage(
|
|
18845
|
+
redactAuthCodeMessage(redactAuthCodeApi as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
18592
18846
|
return
|
|
18593
18847
|
}
|
|
18594
18848
|
pendingReauthFlows.delete(interceptKey)
|
|
@@ -19051,12 +19305,12 @@ async function handleInbound(
|
|
|
19051
19305
|
if (isSteering) {
|
|
19052
19306
|
// Explicit steer: mark with 🤝 on the inbound message; leave the
|
|
19053
19307
|
// existing StatusReactionController running for the in-flight turn.
|
|
19054
|
-
void
|
|
19308
|
+
void sendReaction(chat_id, msgId, '🤝').catch(() => {})
|
|
19055
19309
|
} else if (priorTurnInFlight) {
|
|
19056
19310
|
// Queued mid-turn message (new default): don't touch the existing
|
|
19057
19311
|
// controller; just ack the inbound message with 👀 so the user
|
|
19058
19312
|
// knows we received it, without disrupting the in-flight reaction.
|
|
19059
|
-
void
|
|
19313
|
+
void sendReaction(chat_id, msgId, '👀').catch(() => {})
|
|
19060
19314
|
// #203: time-to-ack metric — measure gateway-receive → ack-post delta.
|
|
19061
19315
|
logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
|
|
19062
19316
|
} else {
|
|
@@ -19099,9 +19353,7 @@ async function handleInbound(
|
|
|
19099
19353
|
// msgId here and use it as the reaction-session token in log events.
|
|
19100
19354
|
const ctrlTurnToken = `${chat_id}:${msgId}`
|
|
19101
19355
|
const ctrl = new StatusReactionController(async (emoji) => {
|
|
19102
|
-
await
|
|
19103
|
-
{ type: 'emoji', emoji: emoji as ReactionTypeEmoji['emoji'] },
|
|
19104
|
-
])
|
|
19356
|
+
await sendReaction(chat_id, msgId, emoji as ReactionTypeEmoji['emoji'])
|
|
19105
19357
|
// #203: every status-reaction transition is a user-visible signal.
|
|
19106
19358
|
signalTracker.noteSignal(key, Date.now())
|
|
19107
19359
|
}, allowedReactions, {
|
|
@@ -19184,9 +19436,7 @@ async function handleInbound(
|
|
|
19184
19436
|
}
|
|
19185
19437
|
}
|
|
19186
19438
|
} else if (access.ackReaction) {
|
|
19187
|
-
void
|
|
19188
|
-
{ type: 'emoji', emoji: access.ackReaction as ReactionTypeEmoji['emoji'] },
|
|
19189
|
-
]).catch(() => {})
|
|
19439
|
+
void sendReaction(chat_id, msgId, access.ackReaction as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
19190
19440
|
// #203: time-to-ack metric for the custom-ack-reaction path.
|
|
19191
19441
|
logStreamingEvent({ kind: 'inbound_ack', chatId: chat_id, messageId: msgId, ackDelayMs: Date.now() - inboundReceivedAt })
|
|
19192
19442
|
}
|
|
@@ -19249,6 +19499,19 @@ async function handleInbound(
|
|
|
19249
19499
|
: undefined
|
|
19250
19500
|
const replyToTextEscaped = formatReplyToText(replyToTextRaw, REPLY_TO_TEXT_MAX)
|
|
19251
19501
|
|
|
19502
|
+
// Forwarded-message origin context. `forward_origin` is stamped by
|
|
19503
|
+
// Telegram's servers (Bot API 7.0+) — the forwarding user cannot forge
|
|
19504
|
+
// it via the message body, so it rides the trusted attrs lane and is
|
|
19505
|
+
// NEVER folded into the body text. Coalesced bursts pass their deduped
|
|
19506
|
+
// per-entry origins; direct/bypass paths parse this ctx's own origin.
|
|
19507
|
+
// Same raw-vs-escaped split as reply_to_text: `forwardOrigins` carries
|
|
19508
|
+
// raw (truncated) names for SQLite, `buildForwardOriginMeta` escapes at
|
|
19509
|
+
// the channel-meta boundary.
|
|
19510
|
+
const forwardOrigins = coalescedForwardOrigins
|
|
19511
|
+
?? dedupeForwardOrigins([parseForwardOrigin(ctx.message?.forward_origin)])
|
|
19512
|
+
const forwardOriginMeta = buildForwardOriginMeta(forwardOrigins)
|
|
19513
|
+
const primaryForwardOrigin = forwardOrigins[0]
|
|
19514
|
+
|
|
19252
19515
|
if (HISTORY_ENABLED) {
|
|
19253
19516
|
try {
|
|
19254
19517
|
recordInbound({
|
|
@@ -19262,6 +19525,11 @@ async function handleInbound(
|
|
|
19262
19525
|
attachment_kind: attachment?.kind,
|
|
19263
19526
|
reply_to_message_id: replyToMessageId ?? null,
|
|
19264
19527
|
reply_to_text: replyToText ?? null,
|
|
19528
|
+
forwarded_from: primaryForwardOrigin?.name ?? null,
|
|
19529
|
+
forwarded_from_type: primaryForwardOrigin?.type ?? null,
|
|
19530
|
+
forwarded_from_id: primaryForwardOrigin?.id != null ? String(primaryForwardOrigin.id) : null,
|
|
19531
|
+
forwarded_date: forwardOriginDateIso(primaryForwardOrigin),
|
|
19532
|
+
forwarded_message_id: primaryForwardOrigin?.messageId ?? null,
|
|
19265
19533
|
})
|
|
19266
19534
|
} catch (err) {
|
|
19267
19535
|
process.stderr.write(`telegram gateway: history recordInbound failed: ${err}\n`)
|
|
@@ -19367,6 +19635,11 @@ async function handleInbound(
|
|
|
19367
19635
|
// Use the XML-escaped form for the meta — the raw form is in the
|
|
19368
19636
|
// SQLite buffer for verbatim retrieval via get_recent_messages.
|
|
19369
19637
|
...(replyToTextEscaped != null && replyToTextEscaped.length > 0 ? { reply_to_text: replyToTextEscaped } : {}),
|
|
19638
|
+
// Forwarded-message origin (server-stamped, attrs-only — see above).
|
|
19639
|
+
// forwarded_from / forwarded_from_type / forwarded_from_id /
|
|
19640
|
+
// forwarded_date, plus numbered _2.. siblings for a multi-origin
|
|
19641
|
+
// burst. Names are XML-escaped inside buildForwardOriginMeta.
|
|
19642
|
+
...forwardOriginMeta,
|
|
19370
19643
|
// queued="true" when mid-turn with no steer prefix (new default), or
|
|
19371
19644
|
// with explicit /queue or /q prefix (legacy alias).
|
|
19372
19645
|
...((isQueuedMidTurn || isQueuedPrefix) ? { queued: 'true' } : {}),
|
|
@@ -20529,7 +20802,7 @@ async function sweepBeforeSelfRestart(): Promise<void> {
|
|
|
20529
20802
|
try {
|
|
20530
20803
|
await sweepActiveReactions(
|
|
20531
20804
|
agentDir,
|
|
20532
|
-
(chatId, messageId) =>
|
|
20805
|
+
(chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
|
|
20533
20806
|
{ log: (msg) => process.stderr.write(`telegram gateway: pre-restart reaction sweep — ${msg}\n`) },
|
|
20534
20807
|
)
|
|
20535
20808
|
} catch (err) {
|
|
@@ -23085,9 +23358,14 @@ function wouldFireFleetAutoFallback(): boolean {
|
|
|
23085
23358
|
* so the user sees the outcome inline with the original "Model
|
|
23086
23359
|
* unavailable" card.
|
|
23087
23360
|
*/
|
|
23088
|
-
async function fireFleetAutoFallback(
|
|
23361
|
+
async function fireFleetAutoFallback(
|
|
23362
|
+
triggerAgent: string,
|
|
23363
|
+
untilMs?: number,
|
|
23364
|
+
parsedResetAt?: Date,
|
|
23365
|
+
trigger?: 'rate-limit',
|
|
23366
|
+
): Promise<void> {
|
|
23089
23367
|
return fleetFallbackGate.fire(
|
|
23090
|
-
() => doFireFleetAutoFallback(triggerAgent, untilMs),
|
|
23368
|
+
() => doFireFleetAutoFallback(triggerAgent, untilMs, parsedResetAt, trigger),
|
|
23091
23369
|
(err) => {
|
|
23092
23370
|
process.stderr.write(
|
|
23093
23371
|
`telegram gateway: [fleet-fallback] error agent=${triggerAgent}: ${(err as Error)?.message ?? err}\n`,
|
|
@@ -23096,6 +23374,45 @@ async function fireFleetAutoFallback(triggerAgent: string, untilMs?: number): Pr
|
|
|
23096
23374
|
)
|
|
23097
23375
|
}
|
|
23098
23376
|
|
|
23377
|
+
// ─── 429 throttle tier — side-effect wiring ─────────────────────────────────
|
|
23378
|
+
// Decision + notice text live in throttle-tier.ts (pure); the SEQUENCING
|
|
23379
|
+
// (broker mark → fleet-deduped notice → jittered retry nudge with the
|
|
23380
|
+
// live-turn safety guards) lives in throttle-tier-wiring.ts so it is
|
|
23381
|
+
// unit-testable with injected deps. This block only binds the real gateway
|
|
23382
|
+
// dependencies.
|
|
23383
|
+
const throttleTierRunner = createThrottleTierRunner({
|
|
23384
|
+
agentName: process.env.SWITCHROOM_AGENT_NAME ?? '',
|
|
23385
|
+
getBrokerClient: () =>
|
|
23386
|
+
getAuthBrokerClient(process.env.SWITCHROOM_AGENT_NAME ?? ''),
|
|
23387
|
+
listNoticeChats: () => loadAccess().allowFrom,
|
|
23388
|
+
sendNotice: (chat_id, markdown) => {
|
|
23389
|
+
// Status notice, not the user's answer — silence the ping (same posture
|
|
23390
|
+
// as the fleet-fallback announcement).
|
|
23391
|
+
void swallowingApiCall(
|
|
23392
|
+
// allow-raw-bot-api: wrapped in swallowingApiCall (retry policy)
|
|
23393
|
+
() => bot.api.sendRichMessage(chat_id, richMessage(markdown), { disable_notification: true }),
|
|
23394
|
+
{ chat_id: String(chat_id), verb: 'throttle-tier:notify' },
|
|
23395
|
+
)
|
|
23396
|
+
},
|
|
23397
|
+
resumeDecide: (ts) => fleetFallbackResumeGate.decide(ts),
|
|
23398
|
+
newestActiveTurnStartedAtMs,
|
|
23399
|
+
turnInFlight: () => turnInFlightForGate(),
|
|
23400
|
+
deferRestartToTurnComplete: (agentName, reason) => {
|
|
23401
|
+
// Same lever the schedule_restart IPC uses: the restart drains at the
|
|
23402
|
+
// turn-complete gate (never SIGTERM-now under a live turn). Note the
|
|
23403
|
+
// pending-restart drain cap (PENDING_RESTART_DRAIN_CAP_MS) still bounds
|
|
23404
|
+
// a never-idling session — deliberate, matching scheduled restarts.
|
|
23405
|
+
process.stderr.write(
|
|
23406
|
+
`telegram gateway: [throttle-tier] restart deferred to turn-complete agent=${agentName} reason=${reason}\n`,
|
|
23407
|
+
)
|
|
23408
|
+
pendingRestarts.set(agentName, Date.now())
|
|
23409
|
+
},
|
|
23410
|
+
restartNow: (agentName, reason) => {
|
|
23411
|
+
triggerSelfRestart(agentName, reason)
|
|
23412
|
+
},
|
|
23413
|
+
log: (m) => process.stderr.write(`telegram gateway: ${m}\n`),
|
|
23414
|
+
})
|
|
23415
|
+
|
|
23099
23416
|
/**
|
|
23100
23417
|
* Broadcast a fleet-fallback FAILURE notice to every authorized chat.
|
|
23101
23418
|
*
|
|
@@ -23157,7 +23474,12 @@ function broadcastFleetFallbackFailure(triggerAgent: string, reason: string): vo
|
|
|
23157
23474
|
* user-visible announcement was broadcast). False on no-op /
|
|
23158
23475
|
* error / idempotent-skip — caller uses this to decide whether to
|
|
23159
23476
|
* arm the post-fire suppression window. */
|
|
23160
|
-
async function doFireFleetAutoFallback(
|
|
23477
|
+
async function doFireFleetAutoFallback(
|
|
23478
|
+
triggerAgent: string,
|
|
23479
|
+
untilMs?: number,
|
|
23480
|
+
parsedResetAt?: Date,
|
|
23481
|
+
trigger?: 'rate-limit',
|
|
23482
|
+
): Promise<boolean> {
|
|
23161
23483
|
try {
|
|
23162
23484
|
const client = await getAuthBrokerClient(triggerAgent)
|
|
23163
23485
|
if (!client) {
|
|
@@ -23206,6 +23528,16 @@ async function doFireFleetAutoFallback(triggerAgent: string, untilMs?: number):
|
|
|
23206
23528
|
},
|
|
23207
23529
|
triggerAgent,
|
|
23208
23530
|
tz,
|
|
23531
|
+
// Enriched card (429 throttle tier): the RAW reset parsed from the
|
|
23532
|
+
// error prose, so the announcement names when the old account frees
|
|
23533
|
+
// even when its live probe returned nothing.
|
|
23534
|
+
parsedResetAt,
|
|
23535
|
+
// Throttle-tier escalation: a terminal transient 429 with a long
|
|
23536
|
+
// parsed reset NEGATES the usage-limit reading, so the old account's
|
|
23537
|
+
// utilization probe typically classifies healthy — the idempotency
|
|
23538
|
+
// guard would self-cancel the swap ("probed healthy / Stale event?").
|
|
23539
|
+
// Trust the terminal parsed-reset signal over the utilization probe.
|
|
23540
|
+
rateLimitTrigger: trigger === 'rate-limit',
|
|
23209
23541
|
})
|
|
23210
23542
|
process.stderr.write(
|
|
23211
23543
|
`telegram gateway: [fleet-fallback] outcome=${outcome.kind} agent=${triggerAgent}` +
|
|
@@ -24905,7 +25237,18 @@ bot.command('usage', async ctx => {
|
|
|
24905
25237
|
// /auth snapshot does. switchroomReply routes through the rich path
|
|
24906
25238
|
// (replyWithRichMessage), which accepts reply_markup. Build a grammy
|
|
24907
25239
|
// InlineKeyboard so the markup type matches switchroomReply's contract.
|
|
24908
|
-
|
|
25240
|
+
// Defense-in-depth (mirrors the operator-private `/auth` treatment,
|
|
25241
|
+
// gateway.ts ~24036): outside a private chat, strip the
|
|
25242
|
+
// `auth:use:<label>` "Switch fleet" rows so the fleet-wide account-
|
|
25243
|
+
// swap button is never even offered in a group/forum. The dispatch-
|
|
25244
|
+
// site allowFrom gate is the load-bearing control; this just avoids
|
|
25245
|
+
// dangling a privileged button in front of non-operators.
|
|
25246
|
+
let kbRows = buildSnapshotKeyboard(snapshots, { now: new Date(), demo })
|
|
25247
|
+
if (ctx.chat?.type !== 'private') {
|
|
25248
|
+
kbRows = kbRows.filter(
|
|
25249
|
+
(row) => !row.some((b) => b.callbackData?.startsWith('auth:use:')),
|
|
25250
|
+
)
|
|
25251
|
+
}
|
|
24909
25252
|
const keyboard = new InlineKeyboard()
|
|
24910
25253
|
kbRows.forEach((row, ri) => {
|
|
24911
25254
|
if (ri > 0) keyboard.row()
|
|
@@ -25033,7 +25376,19 @@ bot.on('callback_query:data', async ctx => {
|
|
|
25033
25376
|
// Auth dashboard buttons (`auth:<verb>:<agent>[:<slot>]`). Route
|
|
25034
25377
|
// through a dedicated handler that maps each action onto the
|
|
25035
25378
|
// existing CLI invocations plus dashboard refresh.
|
|
25379
|
+
//
|
|
25380
|
+
// Strict allowFrom gate like every other mutating callback family
|
|
25381
|
+
// (`eff:`/`apv:`/`cfg:`/`cn:`/`mdl:`). Its absence was a vulnerability:
|
|
25382
|
+
// `auth:use:<label>` drives `client.setActive(label)`, a fleet-wide
|
|
25383
|
+
// OAuth account swap — on an admin forum/supergroup agent with an empty
|
|
25384
|
+
// group allowFrom, any member could tap it and swap the active account.
|
|
25036
25385
|
if (data.startsWith('auth:')) {
|
|
25386
|
+
const access = loadAccess()
|
|
25387
|
+
const senderId = String(ctx.from?.id ?? '')
|
|
25388
|
+
if (!access.allowFrom.includes(senderId)) {
|
|
25389
|
+
await ctx.answerCallbackQuery({ text: 'Not authorized.' })
|
|
25390
|
+
return
|
|
25391
|
+
}
|
|
25037
25392
|
await handleAuthDashboardCallback(ctx)
|
|
25038
25393
|
return
|
|
25039
25394
|
}
|
|
@@ -26735,9 +27090,7 @@ async function handleAckOnly(
|
|
|
26735
27090
|
const chat_id = String(ctx.chat!.id)
|
|
26736
27091
|
const msgId = ctx.message?.message_id
|
|
26737
27092
|
if (msgId != null) {
|
|
26738
|
-
void
|
|
26739
|
-
{ type: 'emoji', emoji: (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji'] },
|
|
26740
|
-
]).catch(() => {})
|
|
27093
|
+
void sendReaction(chat_id, msgId, (opts.emoji ?? '👀') as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
26741
27094
|
}
|
|
26742
27095
|
const prefix = opts.warn ? 'WARN ' : ''
|
|
26743
27096
|
process.stderr.write(`telegram gateway: ${prefix}inbound ${kind} ack-only chat_id=${chat_id} from=${ctx.from?.id ?? '?'}\n`)
|
|
@@ -26770,9 +27123,7 @@ async function handleRefusal(
|
|
|
26770
27123
|
const msgId = ctx.message?.message_id
|
|
26771
27124
|
const messageThreadId = ctx.message?.message_thread_id
|
|
26772
27125
|
if (msgId != null) {
|
|
26773
|
-
void
|
|
26774
|
-
{ type: 'emoji', emoji: '🚫' as ReactionTypeEmoji['emoji'] },
|
|
26775
|
-
]).catch(() => {})
|
|
27126
|
+
void sendReaction(chat_id, msgId, '🚫' as ReactionTypeEmoji['emoji']).catch(() => {})
|
|
26776
27127
|
}
|
|
26777
27128
|
// #1075: thread-id-bearing — swallow on THREAD_NOT_FOUND so a
|
|
26778
27129
|
// deleted topic doesn't crash the refusal handler.
|
|
@@ -27831,7 +28182,7 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
|
|
|
27831
28182
|
if (startupAgentDir != null) {
|
|
27832
28183
|
void sweepActiveReactions(
|
|
27833
28184
|
startupAgentDir,
|
|
27834
|
-
(chatId, messageId) =>
|
|
28185
|
+
(chatId, messageId) => sendReaction(chatId, messageId, '👍' as ReactionTypeEmoji['emoji']),
|
|
27835
28186
|
{ log: (msg) => process.stderr.write(`telegram gateway: startup reaction sweep — ${msg}\n`) },
|
|
27836
28187
|
)
|
|
27837
28188
|
}
|
|
@@ -28740,30 +29091,41 @@ void (async () => {
|
|
|
28740
29091
|
// Best-effort: a registry read failure (e.g. no worktree dir
|
|
28741
29092
|
// on an agent that never claims one) must not affect the
|
|
28742
29093
|
// primary agentCwd watch.
|
|
28743
|
-
|
|
28744
|
-
|
|
28745
|
-
|
|
28746
|
-
|
|
28747
|
-
|
|
28748
|
-
|
|
28749
|
-
|
|
28750
|
-
|
|
28751
|
-
|
|
28752
|
-
|
|
28753
|
-
|
|
28754
|
-
|
|
28755
|
-
|
|
28756
|
-
|
|
28757
|
-
|
|
28758
|
-
|
|
28759
|
-
|
|
28760
|
-
|
|
28761
|
-
|
|
28762
|
-
|
|
28763
|
-
|
|
28764
|
-
|
|
28765
|
-
|
|
28766
|
-
|
|
29094
|
+
// On every ~1s rescan tick the provider does BOTH:
|
|
29095
|
+
// 1. advances the heartbeat of every worktree THIS agent owns
|
|
29096
|
+
// — the F1/H3 production driver that keeps `touchHeartbeat`
|
|
29097
|
+
// alive (it had ZERO callers, so every claim read "stale"
|
|
29098
|
+
// 10 min after creation and the reaper's staleness
|
|
29099
|
+
// guarantee collapsed); throttled to ≤1 write / 2 min per
|
|
29100
|
+
// record, and
|
|
29101
|
+
// 2. returns the fail-CLOSED set of owned worktree cwds for the
|
|
29102
|
+
// watcher to also watch (#1116 / Gap-2 ownership predicate).
|
|
29103
|
+
// Extracted to telegram-plugin/worktree-watch-cwds.ts as
|
|
29104
|
+
// `makeWorktreeWatchProvider` so the wiring — specifically that
|
|
29105
|
+
// the provider ACTUALLY drives heartbeats, not just cwds — is
|
|
29106
|
+
// under direct unit test (see the provider behaviour test in
|
|
29107
|
+
// telegram-plugin/tests/worktree-watch-cwds.test.ts). Fully
|
|
29108
|
+
// best-effort: it never throws out of the provider.
|
|
29109
|
+
extraWatchCwdsProvider: makeWorktreeWatchProvider({
|
|
29110
|
+
self: process.env.SWITCHROOM_AGENT_NAME,
|
|
29111
|
+
// Durable, non-env identity fallback (#1116 / #2893): when
|
|
29112
|
+
// SWITCHROOM_AGENT_NAME is somehow unset, derive this agent's
|
|
29113
|
+
// own identity from its own directory so worktree ownership
|
|
29114
|
+
// still resolves (env is only the fast path). `watcherAgentDir`
|
|
29115
|
+
// is guaranteed non-null in this branch (the whole watcher is
|
|
29116
|
+
// gated on it above). Kill-switch
|
|
29117
|
+
// SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the pre-fix
|
|
29118
|
+
// env-only behaviour. One `agentDir` governs BOTH the heartbeat
|
|
29119
|
+
// refresh and the cwd derivation.
|
|
29120
|
+
agentDir:
|
|
29121
|
+
process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
|
|
29122
|
+
? undefined
|
|
29123
|
+
: watcherAgentDir,
|
|
29124
|
+
listRecords: listWorktreeRecords,
|
|
29125
|
+
touchHeartbeat: touchWorktreeHeartbeat,
|
|
29126
|
+
log: (msg) =>
|
|
29127
|
+
process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
29128
|
+
}),
|
|
28767
29129
|
// Bug 0 fix: previously omitted, leaving the watcher unable to
|
|
28768
29130
|
// write liveness/stall/turn_end updates to the registry DB.
|
|
28769
29131
|
// Liveness writes are now persisted across the gateway lifetime.
|