switchroom 0.16.47 → 0.17.0
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 +3 -1
- package/dist/auth-broker/index.js +24 -8
- package/dist/cli/drive-write-pretool.mjs +0 -5
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +1358 -1030
- package/dist/cli/ui/index.html +84 -12
- package/dist/host-control/main.js +53 -17
- package/dist/vault/approvals/kernel-server.js +4 -1
- package/dist/vault/broker/server.js +201 -56
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +1 -1
- package/profiles/_base/start.sh.hbs +54 -3
- package/skills/switchroom-architecture/telegram.md +8 -15
- package/skills/switchroom-cli/SKILL.md +4 -5
- package/skills/telegram-test-harness/SKILL.md +1 -1
- package/telegram-plugin/README.md +18 -29
- package/telegram-plugin/bridge/bridge.ts +1 -41
- package/telegram-plugin/bridge/tool-filter.ts +3 -4
- package/telegram-plugin/dist/bridge/bridge.js +8 -43
- package/telegram-plugin/dist/gateway/gateway.js +682 -773
- package/telegram-plugin/dist/server.js +8 -43
- package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
- package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
- package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
- package/telegram-plugin/gateway/gateway.ts +518 -624
- package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
- package/telegram-plugin/gateway/inbound-spool.ts +108 -10
- package/telegram-plugin/gateway/model-command.ts +51 -3
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
- package/telegram-plugin/gateway/represent-guard.ts +28 -11
- package/telegram-plugin/gateway/status-pin-store.ts +124 -45
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
- package/telegram-plugin/history.ts +5 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
- package/telegram-plugin/registry/subagents-schema.ts +126 -1
- package/telegram-plugin/registry/turns-schema.ts +65 -1
- package/telegram-plugin/session-tail.ts +26 -4
- package/telegram-plugin/slot-banner-driver.ts +42 -2
- package/telegram-plugin/status-query-telemetry.ts +100 -0
- package/telegram-plugin/stream-reply-handler.ts +15 -16
- package/telegram-plugin/subagent-watcher.ts +182 -30
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
- package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
- package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
- package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
- package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
- package/telegram-plugin/tests/model-command.test.ts +54 -1
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
- package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
- package/telegram-plugin/tests/registry-turns.test.ts +67 -0
- package/telegram-plugin/tests/represent-guard.test.ts +42 -6
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
- package/telegram-plugin/tests/session-tail.test.ts +10 -1
- package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
- package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
- package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
- package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
- package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
- package/telegram-plugin/tests/tool-filter.test.ts +1 -3
- package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
- package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
- package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
- package/telegram-plugin/tool-activity-summary.ts +19 -0
- package/telegram-plugin/turn-flush-safety.ts +16 -1
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
- package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
- package/telegram-plugin/worker-activity-feed.ts +75 -15
- package/vendor/hindsight-memory/CHANGELOG.md +24 -0
- package/vendor/hindsight-memory/README.md +5 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
- package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
- package/vendor/hindsight-memory/scripts/lib/content.py +4 -1
- package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
- package/vendor/hindsight-memory/scripts/recall.py +74 -1
- package/vendor/hindsight-memory/scripts/retain.py +8 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
- package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
- package/vendor/hindsight-memory/settings.json +4 -0
- package/vendor/hindsight-memory/tests/test_client.py +130 -0
- package/vendor/hindsight-memory/tests/test_config.py +47 -0
- package/vendor/hindsight-memory/tests/test_content.py +18 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
- package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
- package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
- package/telegram-plugin/silent-reply.ts +0 -58
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
- package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
- package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
|
@@ -114,7 +114,7 @@ import { renderVaultRequestAccessCard } from './vault-request-access-card.js'
|
|
|
114
114
|
import { createPermissionCardStore } from './permission-card-store.js'
|
|
115
115
|
import { pickRecoveredPermissionOrigin } from './permission-card-origin.js'
|
|
116
116
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
117
|
-
import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, type SessionActivityHeader } from '../tool-activity-summary.js'
|
|
117
|
+
import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, formatStepSuffix, type SessionActivityHeader } from '../tool-activity-summary.js'
|
|
118
118
|
import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
|
|
119
119
|
import { toolLabel } from '../tool-labels.js'
|
|
120
120
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
@@ -285,6 +285,7 @@ import {
|
|
|
285
285
|
} from '../active-reactions.js'
|
|
286
286
|
import { sweepActiveReactions } from '../active-reactions-sweep.js'
|
|
287
287
|
import { flushOnAgentDisconnect } from './disconnect-flush.js'
|
|
288
|
+
import { markBusyKeyLockstep, reapOrphanBusyKeys } from './busy-key-reaper.js'
|
|
288
289
|
import { PreambleSuppressor } from './preamble-suppressor.js'
|
|
289
290
|
import {
|
|
290
291
|
fetchFolderPage,
|
|
@@ -383,6 +384,7 @@ import {
|
|
|
383
384
|
import { loadObligations, persistObligations } from './obligation-store.js'
|
|
384
385
|
import {
|
|
385
386
|
loadStatusPins,
|
|
387
|
+
mutateStatusPinRow,
|
|
386
388
|
pinnedMessageIsOurs,
|
|
387
389
|
reconcileAndPersistStatusPin,
|
|
388
390
|
runStatusPinBootCleanup,
|
|
@@ -392,6 +394,7 @@ import {
|
|
|
392
394
|
} from './status-pin-store.js'
|
|
393
395
|
import { driveEscalation } from './escalation-drive.js'
|
|
394
396
|
import { shouldSuppressRepresent } from './represent-guard.js'
|
|
397
|
+
import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
|
|
395
398
|
import { createInboundSpool } from './inbound-spool.js'
|
|
396
399
|
import { purgeStaleTurnsForChat } from './turn-state-purge.js'
|
|
397
400
|
import { decideInboundDelivery } from './inbound-delivery-gate.js'
|
|
@@ -422,6 +425,7 @@ import {
|
|
|
422
425
|
forgetDelivery,
|
|
423
426
|
shouldTrackDelivery,
|
|
424
427
|
isTrackableResumeSynthetic,
|
|
428
|
+
isRedeliverySuspended,
|
|
425
429
|
type PendingDelivery,
|
|
426
430
|
} from './inbound-delivery-confirm.js'
|
|
427
431
|
import { createPendingPermissionBuffer } from './pending-permission-decisions.js'
|
|
@@ -434,6 +438,7 @@ import { chatKey, chatKeyWithSuffix, chatIdOfChatKey } from './chat-key.js'
|
|
|
434
438
|
import { shadowEmit, isMachineInTurn, isDeliveryCutoverEnabled } from './inbound-delivery-machine-shadow.js'
|
|
435
439
|
import type { ChatKey as _ChatKey } from './inbound-delivery-machine.js'
|
|
436
440
|
import { dispatchEffects, isDispatchEnabled } from './inbound-delivery-machine-dispatch.js'
|
|
441
|
+
import { probeGateParity } from './gate-parity-probe.js'
|
|
437
442
|
import { maybeFireWarmup } from './prefix-warmup.js'
|
|
438
443
|
import {
|
|
439
444
|
buildVaultGrantApprovedInbound,
|
|
@@ -616,20 +621,26 @@ import {
|
|
|
616
621
|
findLatestTurnIfInterrupted,
|
|
617
622
|
findRecentTurnsForChat,
|
|
618
623
|
getTurnByKey,
|
|
624
|
+
markTurnResumed,
|
|
619
625
|
} from '../registry/turns-schema.js'
|
|
620
626
|
import {
|
|
621
627
|
buildResumeInterruptedInbound,
|
|
622
628
|
buildResumeWatchdogReportInbound,
|
|
623
629
|
selectResumeBuilder,
|
|
624
630
|
} from './resume-inbound-builder.js'
|
|
625
|
-
import { applySubagentsSchema, getSubagentByJsonlId } from '../registry/subagents-schema.js'
|
|
631
|
+
import { applySubagentsSchema, getSubagentByJsonlId, resolveSubagentOriginTurnKey } from '../registry/subagents-schema.js'
|
|
626
632
|
import { resolveWorkerFeedDispatch, type WorkerFeedDispatch } from './worker-feed-dispatch.js'
|
|
627
633
|
import {
|
|
628
634
|
resolveSubagentStatusSurface,
|
|
629
635
|
isOrphanSubagentStatusEnabled,
|
|
630
636
|
} from './subagent-status-surface.js'
|
|
631
637
|
import { formatIdleFooter } from '../idle-footer.js'
|
|
632
|
-
import {
|
|
638
|
+
import {
|
|
639
|
+
deriveStatusQueryTelemetry,
|
|
640
|
+
formatStatusQueryLine,
|
|
641
|
+
formatStatusQueryUxFailureLine,
|
|
642
|
+
type StatusQuerySurfaces,
|
|
643
|
+
} from '../status-query-telemetry.js'
|
|
633
644
|
|
|
634
645
|
// ─── Stderr logging ───────────────────────────────────────────────────────
|
|
635
646
|
// Install the line-stamper FIRST so it wraps closest to the original
|
|
@@ -1403,9 +1414,15 @@ function resolveSubagentOriginChat(
|
|
|
1403
1414
|
): { chatId: string; threadId?: number } | null {
|
|
1404
1415
|
if (turnsDb == null) return null
|
|
1405
1416
|
try {
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1417
|
+
// Transitive walk: a NESTED (depth-2+) worker's own parent_turn_key is
|
|
1418
|
+
// NULL by construction (its dispatching context is another sub-agent,
|
|
1419
|
+
// not a gateway turn) — resolveSubagentOriginTurnKey follows the
|
|
1420
|
+
// parent_agent_id chain to the ancestor row that WAS stamped at
|
|
1421
|
+
// main-turn dispatch time, so nested cards + handbacks route to the
|
|
1422
|
+
// originating chat/topic instead of falling back to the owner DM.
|
|
1423
|
+
const originKey = resolveSubagentOriginTurnKey(turnsDb, agentId)
|
|
1424
|
+
if (originKey == null) return null
|
|
1425
|
+
const turn = getTurnByKey(turnsDb, originKey)
|
|
1409
1426
|
if (turn == null || turn.chat_id.length === 0) return null
|
|
1410
1427
|
const threadNum =
|
|
1411
1428
|
turn.thread_id != null && turn.thread_id.length > 0
|
|
@@ -1536,8 +1553,8 @@ const deferredDoneReactions = new DeferredDoneReactions<StatusReactionController
|
|
|
1536
1553
|
purge: (key) => purgeReactionTracking(key),
|
|
1537
1554
|
})
|
|
1538
1555
|
|
|
1539
|
-
// #546 — outbound content-dedup window. PR #599 introduced the
|
|
1540
|
-
// sites (`outboundDedup.check` / `.record` in executeReply
|
|
1556
|
+
// #546 — outbound content-dedup window. PR #599 introduced the read
|
|
1557
|
+
// sites (`outboundDedup.check` / `.record` in executeReply and
|
|
1541
1558
|
// turn-flush) but the declaration was lost in a merge somewhere — every reply
|
|
1542
1559
|
// path threw `outboundDedup is not defined` at runtime, blocking ALL outbound
|
|
1543
1560
|
// from the agent. Restore the module-level singleton here.
|
|
@@ -1577,6 +1594,18 @@ const activeTurnStartedAt = new Map<string, number>()
|
|
|
1577
1594
|
// reading activeTurnStartedAt because they want the receipt timestamp.
|
|
1578
1595
|
const claudeBusyKeys = new Set<string>()
|
|
1579
1596
|
|
|
1597
|
+
// #2787 Mechanism B — insertion timestamps for the busy keys above, so the
|
|
1598
|
+
// confirm sweep can reap an orphaned marker. `busy` is stamped EAGERLY at
|
|
1599
|
+
// delivery and cleared only at turn_end; a delivered-but-never-enqueued inbound
|
|
1600
|
+
// (e.g. a composer strand or an enqueue-ack mismatch) therefore leaves its key
|
|
1601
|
+
// stuck forever, wedging the legacy-regime idle-drain (turnInFlightForGate reads
|
|
1602
|
+
// claudeBusyKeys.size) for EVERY topic until the ~300s silence poke — the
|
|
1603
|
+
// 5-minute all-topics stall of #1922. The reaper (reapOrphanBusyKeys) uses these
|
|
1604
|
+
// timestamps to bound that dangle. The delivery-machine cutover regime is already
|
|
1605
|
+
// immune (it tracks one activeTurn + TTL tick, never a per-key set); this bounds
|
|
1606
|
+
// the legacy path it hasn't replaced yet.
|
|
1607
|
+
const claudeBusyKeySince = new Map<string, number>()
|
|
1608
|
+
|
|
1580
1609
|
/**
|
|
1581
1610
|
* #2527 observability: count emoji transitions per status-reaction controller
|
|
1582
1611
|
* so `turn_no_reply_warn` can report how many reaction changes happened while
|
|
@@ -1610,10 +1639,68 @@ function markClaudeBusyForInbound(m: {
|
|
|
1610
1639
|
if (Number.isFinite(n)) tid = n
|
|
1611
1640
|
}
|
|
1612
1641
|
const key = chatKey(m.chatId, tid)
|
|
1613
|
-
|
|
1642
|
+
// #2787: lockstep add + idle→busy timestamp. Keyed on set membership (see
|
|
1643
|
+
// busy-key-reaper.ts) so a re-mark after a disconnect flush (which cleared
|
|
1644
|
+
// claudeBusyKeys directly) always re-stamps a fresh Date.now() and the orphan
|
|
1645
|
+
// TTL measures the CURRENT dangle, never a stale pre-disconnect timestamp.
|
|
1646
|
+
markBusyKeyLockstep(claudeBusyKeys, claudeBusyKeySince, key, Date.now())
|
|
1614
1647
|
return key
|
|
1615
1648
|
}
|
|
1616
1649
|
|
|
1650
|
+
// #2787 Mechanism B — reap orphaned busy markers. Called from the confirm sweep
|
|
1651
|
+
// ONLY once `currentTurn == null` has been asserted: with no turn in flight, any
|
|
1652
|
+
// surviving claudeBusyKeys entry is definitionally an orphan (a real turn would
|
|
1653
|
+
// hold currentTurn non-null), so clearing a stale one can never clobber a live
|
|
1654
|
+
// turn. Also prunes timestamp entries whose key already left the set (e.g. a
|
|
1655
|
+
// disconnect flush cleared claudeBusyKeys directly) so the map can't grow
|
|
1656
|
+
// unbounded.
|
|
1657
|
+
//
|
|
1658
|
+
// SLOW-DELIVERY SAFETY (the #1922 hazard): `currentTurn == null` is NOT proof of
|
|
1659
|
+
// idle — busy is marked EAGERLY at delivery, and the gateway→claude enqueue-ack
|
|
1660
|
+
// lag can be up to ~5 MINUTES under load (#1922). During that eager-mark→enqueue
|
|
1661
|
+
// window `currentTurn` is null yet the turn is real-and-merely-slow, so a bare
|
|
1662
|
+
// time-grace reaper could reap a genuine delivery and re-open the idle-drain gate
|
|
1663
|
+
// while claude is about to process the very inbound whose key it just reaped
|
|
1664
|
+
// (duplicate / concurrent delivery on the CORE inbound path). A time grace alone
|
|
1665
|
+
// cannot distinguish "slow" from "orphaned".
|
|
1666
|
+
//
|
|
1667
|
+
// So the reap is PROOF-GATED, not just time-gated (option (b), the load-bearing
|
|
1668
|
+
// guarantee): a key is reaped ONLY when it has NO corresponding entry in the
|
|
1669
|
+
// delivery-confirm queue (`deliveryQueue.pending`). A slow-but-real inbound is
|
|
1670
|
+
// tracked there from delivery until claude's `enqueue` ack lands (or forever, via
|
|
1671
|
+
// the never-drop re-deliver loop), so any key still awaiting its turn is present
|
|
1672
|
+
// and skipped — no matter how slow. Only a key that is busy-marked yet has no
|
|
1673
|
+
// pending delivery is a TRUE orphan: either it was acked (its turn ran and should
|
|
1674
|
+
// have cleared busy at turn_end — a genuinely stuck marker) or it was a
|
|
1675
|
+
// steer/interrupt inbound that shouldTrackDelivery excludes (it amends a running
|
|
1676
|
+
// turn, so with currentTurn == null its marker is likewise stale). Reaping only
|
|
1677
|
+
// these can never clobber a slow delivery.
|
|
1678
|
+
//
|
|
1679
|
+
// The time grace is retained as defense-in-depth on top of the proof gate, raised
|
|
1680
|
+
// to a default well above the observed enqueue-ack lag and env-tunable via the
|
|
1681
|
+
// config cascade. This bounds the legacy-regime idle-drain wedge from ~300s
|
|
1682
|
+
// (silence poke) to the grace window without racing a slow delivery.
|
|
1683
|
+
//
|
|
1684
|
+
// Config cascade: SWITCHROOM_BUSY_ORPHAN_TTL_MS is an OVERRIDE-mode scalar
|
|
1685
|
+
// (defaults → profiles → agents; a lower layer's value replaces, not merges).
|
|
1686
|
+
// Default 360_000ms (6 min) — comfortably above the ~5-min #1922 tail so the
|
|
1687
|
+
// grace never trips on a merely-slow delivery even if the proof gate is bypassed.
|
|
1688
|
+
// Clamped to a positive finite value; a degenerate override falls back to default.
|
|
1689
|
+
const _busyOrphanTtlRaw = process.env.SWITCHROOM_BUSY_ORPHAN_TTL_MS
|
|
1690
|
+
const _busyOrphanTtlParsed =
|
|
1691
|
+
_busyOrphanTtlRaw != null && _busyOrphanTtlRaw !== '' ? Number(_busyOrphanTtlRaw) : 360_000
|
|
1692
|
+
const CLAUDE_BUSY_ORPHAN_TTL_MS =
|
|
1693
|
+
Number.isFinite(_busyOrphanTtlParsed) && _busyOrphanTtlParsed > 0 ? _busyOrphanTtlParsed : 360_000
|
|
1694
|
+
function reapOrphanBusyKeysNow(now: number): void {
|
|
1695
|
+
reapOrphanBusyKeys(claudeBusyKeys, claudeBusyKeySince, now, {
|
|
1696
|
+
ttlMs: CLAUDE_BUSY_ORPHAN_TTL_MS,
|
|
1697
|
+
// Proof gate — a key still tracked in the delivery-confirm queue is a
|
|
1698
|
+
// slow-but-real inbound awaiting its enqueue ack, never an orphan (#1922).
|
|
1699
|
+
hasPendingDelivery: (key) => deliveryQueue.pending.has(key),
|
|
1700
|
+
log: (msg) => process.stderr.write(`${msg}\n`),
|
|
1701
|
+
})
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1617
1704
|
// ─── Reliable inbound delivery: deliver-until-acked (the marko drop-wedge) ─
|
|
1618
1705
|
// A delivered inbound is ACKED only by the `enqueue` session-event (claude
|
|
1619
1706
|
// actually started the turn) — NOT by sendToAgent returning true. Until
|
|
@@ -1934,13 +2021,8 @@ const POST_ANSWER_LIVENESS_STALE_MS = parsePostAnswerLivenessMs(
|
|
|
1934
2021
|
process.env.SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS,
|
|
1935
2022
|
) || 30_000
|
|
1936
2023
|
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
const s = Math.floor(ms / 1000)
|
|
1940
|
-
if (s < 60) return `${s}s`
|
|
1941
|
-
const m = Math.floor(s / 60)
|
|
1942
|
-
return `${m}m${(s % 60).toString().padStart(2, '0')}s`
|
|
1943
|
-
}
|
|
2024
|
+
// Live-feed step suffixes render via `formatStepSuffix` (tool-activity-summary):
|
|
2025
|
+
// the CURRENT step's own elapsed, shown only past STEP_TIMER_MIN_MS (10 s).
|
|
1944
2026
|
|
|
1945
2027
|
/**
|
|
1946
2028
|
* Authoritative "is a turn in flight?" for every gate that previously
|
|
@@ -1957,7 +2039,14 @@ function formatFeedElapsed(ms: number): string {
|
|
|
1957
2039
|
* message self-blocks. See the snapshot at the inbound handler.
|
|
1958
2040
|
*/
|
|
1959
2041
|
function turnInFlightForGate(): boolean {
|
|
1960
|
-
|
|
2042
|
+
if (!isDeliveryCutoverEnabled()) return claudeBusyKeys.size > 0
|
|
2043
|
+
// Machine is authoritative. Run the log-only drift canary (#2794): the
|
|
2044
|
+
// imperative `claudeBusyKeys` shadow is still live in parallel, so a
|
|
2045
|
+
// dangerous over-hold divergence (machine holds the gate while the
|
|
2046
|
+
// imperative view is idle) is surfaced without changing behaviour. The
|
|
2047
|
+
// benign orphan-dangle direction — the wedge the machine self-heals — is
|
|
2048
|
+
// NOT flagged. `probeGateParity` returns the machine value unchanged.
|
|
2049
|
+
return probeGateParity(isMachineInTurn(), claudeBusyKeys.size)
|
|
1961
2050
|
}
|
|
1962
2051
|
|
|
1963
2052
|
/**
|
|
@@ -3263,6 +3352,7 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
3263
3352
|
// the markClaudeBusyForInbound on the delivery path. Safe no-op
|
|
3264
3353
|
// when the key was never marked (synthetic purge from a sweep).
|
|
3265
3354
|
claudeBusyKeys.delete(key)
|
|
3355
|
+
claudeBusyKeySince.delete(key) // #2787: keep the orphan-TTL map in lockstep
|
|
3266
3356
|
// #2527: clear the per-key reaction-transition counter and first-reply
|
|
3267
3357
|
// sentinel alongside the controller so we don't leak state across turns.
|
|
3268
3358
|
reactionTransitionCounts.delete(key)
|
|
@@ -3448,6 +3538,7 @@ function releaseTurnBufferGate(key: string, endingTurn?: CurrentTurn): void {
|
|
|
3448
3538
|
// PR3b: keep claudeBusyKeys in sync — same lifecycle as the
|
|
3449
3539
|
// activeTurnStartedAt entry it's mirroring here.
|
|
3450
3540
|
claudeBusyKeys.delete(key)
|
|
3541
|
+
claudeBusyKeySince.delete(key) // #2787: keep the orphan-TTL map in lockstep
|
|
3451
3542
|
// Shadow trace so the structural turn-end metric still records.
|
|
3452
3543
|
// outboundEmitted=true is correct here — we only reach this from
|
|
3453
3544
|
// executeReply AFTER an outbound landed.
|
|
@@ -4451,7 +4542,7 @@ const STATUS_QUERY_RE = /^\s*status\??\s*$/i
|
|
|
4451
4542
|
|
|
4452
4543
|
// ─── Permission handling ──────────────────────────────────────────────────
|
|
4453
4544
|
const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
|
|
4454
|
-
const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number; card_text: string; cards: { chatId: string; messageId: number }[] }>()
|
|
4545
|
+
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 }[] }>()
|
|
4455
4546
|
// PERMISSION_TTL_MS / ttlForTool / the timed-out card builder now live in
|
|
4456
4547
|
// ./permission-timeout.ts (pure + unit-testable). hostd gated verbs get a
|
|
4457
4548
|
// 30-min window; everything else keeps the 10-min default.
|
|
@@ -5652,22 +5743,37 @@ const statusPinStoreFs = {
|
|
|
5652
5743
|
}
|
|
5653
5744
|
const statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING
|
|
5654
5745
|
|
|
5655
|
-
//
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
//
|
|
5667
|
-
//
|
|
5668
|
-
//
|
|
5669
|
-
|
|
5670
|
-
|
|
5746
|
+
// Slot-banner pin persistence (#421 crash-recovery). The slot banner is pinned
|
|
5747
|
+
// in the owner chat when the agent is on a non-default OAuth slot. Rather than a
|
|
5748
|
+
// parallel store + second boot hook, its pin is persisted in the SAME
|
|
5749
|
+
// status-pins.json store under a distinct `banner:` pinKey, so the ONE existing
|
|
5750
|
+
// runStatusPinBootCleanup unpins an orphaned banner on boot for free. Every
|
|
5751
|
+
// write to the shared store is a per-key read-modify-write under the store's
|
|
5752
|
+
// per-path lock (mutateStatusPinRow / reconcileAndPersistStatusPin), so the two
|
|
5753
|
+
// pin kinds (map-backed status pins + this banner row) can never clobber each
|
|
5754
|
+
// other's rows — the "other" rows always come from the authoritative on-disk
|
|
5755
|
+
// file, never from a possibly-stale in-memory snapshot.
|
|
5756
|
+
const BANNER_PIN_KEY = 'banner:owner'
|
|
5757
|
+
// Banner persistence rides the same "store is usable" gate as status pins minus
|
|
5758
|
+
// the worker-pin feature flag: the banner is an independent feature, so it must
|
|
5759
|
+
// persist/recover whenever there's a real state volume (STATIC = no volume/dry
|
|
5760
|
+
// run → no-op). The boot-cleanup gate below is widened to cover this.
|
|
5761
|
+
const bannerPinPersistEnabled = !STATIC
|
|
5762
|
+
|
|
5763
|
+
// Persist (or drop) the slot-banner's pin row into the shared store. Routes
|
|
5764
|
+
// through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
|
|
5765
|
+
// serialised on the store's per-path lock, so the live fg:/wk: status-pin rows
|
|
5766
|
+
// on disk are carried through unchanged and never clobbered. Best-effort +
|
|
5767
|
+
// gated: no-op when the store isn't usable (STATIC). Fire-and-forget — the
|
|
5768
|
+
// mutation never rejects (persist is fail-open, load is fail-open).
|
|
5769
|
+
function persistBannerRow(row: PersistedStatusPin | null): void {
|
|
5770
|
+
if (!bannerPinPersistEnabled) return
|
|
5771
|
+
void mutateStatusPinRow(
|
|
5772
|
+
STATUS_PIN_STORE_PATH,
|
|
5773
|
+
statusPinStoreFs,
|
|
5774
|
+
BANNER_PIN_KEY,
|
|
5775
|
+
row,
|
|
5776
|
+
)
|
|
5671
5777
|
}
|
|
5672
5778
|
|
|
5673
5779
|
// The Bot API surface the pin driver needs. `lockedBot` is defined later; wrap
|
|
@@ -5700,7 +5806,11 @@ function statusPinApi(): PinBotApi {
|
|
|
5700
5806
|
* positives.
|
|
5701
5807
|
*/
|
|
5702
5808
|
async function statusPinBootCleanup(): Promise<void> {
|
|
5703
|
-
|
|
5809
|
+
// Runs when EITHER pin kind could have written the shared store: the
|
|
5810
|
+
// map-backed status pins (statusPinPersistEnabled) or the slot banner
|
|
5811
|
+
// (bannerPinPersistEnabled). A single cleanup drains ALL orphaned rows —
|
|
5812
|
+
// status pins AND banner alike — since they share status-pins.json.
|
|
5813
|
+
if (!statusPinPersistEnabled && !bannerPinPersistEnabled) return
|
|
5704
5814
|
const api = statusPinApi()
|
|
5705
5815
|
const { cleared, total } = await runStatusPinBootCleanup({
|
|
5706
5816
|
path: STATUS_PIN_STORE_PATH,
|
|
@@ -5811,7 +5921,6 @@ async function reconcileStatusPinInner(
|
|
|
5811
5921
|
pinKey,
|
|
5812
5922
|
chatId,
|
|
5813
5923
|
op,
|
|
5814
|
-
snapshotOthers: () => snapshotStatusPinsExcept(pinKey),
|
|
5815
5924
|
applyPin: runReconcile,
|
|
5816
5925
|
})
|
|
5817
5926
|
if (next == null) {
|
|
@@ -6624,19 +6733,56 @@ async function redeliverStrandedInbound(p: PendingDelivery<InboundMessage>): Pro
|
|
|
6624
6733
|
forgetDelivery(deliveryQueue, p.key)
|
|
6625
6734
|
}
|
|
6626
6735
|
}
|
|
6736
|
+
// #2787 Mechanism A — which chats/topics currently hold a live permission or
|
|
6737
|
+
// ask_user card. A pending card is a live interaction for ITS OWN chat, so the
|
|
6738
|
+
// confirm sweep must not re-clear the composer + re-send there. But the OLD guard
|
|
6739
|
+
// (`pendingPermissions.size > 0 || pendingAskUser.size > 0 → return`) suspended
|
|
6740
|
+
// the sweep GLOBALLY: one card parked in a single topic (or in the operator DM,
|
|
6741
|
+
// a different chatId entirely) froze re-delivery of every stranded inbound across
|
|
6742
|
+
// EVERY topic until that one card resolved. This scopes the suspension to the
|
|
6743
|
+
// card's own target. Returns per-topic chatKeys where the topic is known and bare
|
|
6744
|
+
// chatIds where it isn't (a card fanned to operator DMs records chatId only, and
|
|
6745
|
+
// a whole-chat suspension is the safe conservative fallback there); the callsite
|
|
6746
|
+
// tests a delivery entry against both renderings.
|
|
6747
|
+
function sweepSuspendedTargets(): { keys: Set<string>; chats: Set<string> } {
|
|
6748
|
+
const keys = new Set<string>()
|
|
6749
|
+
const chats = new Set<string>()
|
|
6750
|
+
for (const p of pendingPermissions.values()) {
|
|
6751
|
+
for (const c of p.cards) {
|
|
6752
|
+
if (c.threadId != null) keys.add(chatKey(c.chatId, c.threadId))
|
|
6753
|
+
else chats.add(c.chatId)
|
|
6754
|
+
}
|
|
6755
|
+
// A permission whose card send hasn't resolved yet (cards still empty) has
|
|
6756
|
+
// no recorded target — suspend nothing for it; the next sweep sees it once
|
|
6757
|
+
// the send resolves, and the currentTurn guard already covers the in-turn
|
|
6758
|
+
// window a fresh permission request lives in.
|
|
6759
|
+
}
|
|
6760
|
+
for (const a of pendingAskUser.values()) {
|
|
6761
|
+
if (a.threadId != null) keys.add(chatKey(a.chatId, a.threadId))
|
|
6762
|
+
else chats.add(a.chatId)
|
|
6763
|
+
}
|
|
6764
|
+
return { keys, chats }
|
|
6765
|
+
}
|
|
6766
|
+
|
|
6627
6767
|
const _deliveryConfirmSweep = setInterval(() => {
|
|
6628
6768
|
if (!DELIVERY_CONFIRM_ENABLED) return
|
|
6629
6769
|
// Re-deliver ONLY when claude is genuinely idle. `currentTurn` is set solely
|
|
6630
6770
|
// by the enqueue session-event and nulled at turn-end, so `currentTurn != null`
|
|
6631
6771
|
// means a real turn is in flight — re-clearing the composer + re-sending now
|
|
6632
|
-
// would clobber it (the exact mid-turn wedge this queue exists to prevent).
|
|
6633
|
-
// pending permission / ask_user prompt is likewise a live interaction. Defer:
|
|
6634
|
-
// leave the entry pending (it isn't acked) so the next idle sweep retries.
|
|
6772
|
+
// would clobber it (the exact mid-turn wedge this queue exists to prevent).
|
|
6635
6773
|
// NB: claudeBusyKeys (turnInFlightForGate) is set EAGERLY at delivery and
|
|
6636
6774
|
// stays set through a strand, so it is NOT a usable "idle" signal here.
|
|
6637
6775
|
if (currentTurn != null) return
|
|
6638
|
-
|
|
6776
|
+
// #2787 Mechanism B: no turn in flight → reap any orphaned busy marker so a
|
|
6777
|
+
// delivered-but-never-enqueued inbound can't wedge the idle-drain globally.
|
|
6778
|
+
reapOrphanBusyKeysNow(Date.now())
|
|
6779
|
+
// #2787 Mechanism A: a pending permission / ask_user card suspends re-delivery
|
|
6780
|
+
// ONLY for its own chat/topic — never globally. Skip just the stranded entries
|
|
6781
|
+
// whose target holds a live card; sweep the rest so unrelated topics keep
|
|
6782
|
+
// getting re-delivered.
|
|
6783
|
+
const suspended = sweepSuspendedTargets()
|
|
6639
6784
|
for (const p of sweepDeliveryQueue(deliveryQueue, Date.now(), DELIVERY_CONFIRM_TIMEOUT_MS)) {
|
|
6785
|
+
if (isRedeliverySuspended(p.key, suspended)) continue
|
|
6640
6786
|
void redeliverStrandedInbound(p)
|
|
6641
6787
|
}
|
|
6642
6788
|
}, DELIVERY_CONFIRM_SWEEP_MS)
|
|
@@ -6709,8 +6855,61 @@ const inboundSpool = STATIC
|
|
|
6709
6855
|
existsSync: (p) => existsSync(p),
|
|
6710
6856
|
statSizeSync: (p) => statSync(p).size,
|
|
6711
6857
|
},
|
|
6858
|
+
// #2789 B: durability degradation must be visible, not silent. When
|
|
6859
|
+
// spool appends start failing we're back to in-memory-only — a
|
|
6860
|
+
// later crash loses those messages. Latched log so the operator /
|
|
6861
|
+
// health surface sees the transition rather than a silent downgrade.
|
|
6862
|
+
onDegraded: (info) => {
|
|
6863
|
+
process.stderr.write(
|
|
6864
|
+
`telegram gateway: inbound-spool durability ` +
|
|
6865
|
+
`${info.degraded ? 'DEGRADED to in-memory-only' : 'RECOVERED'} ` +
|
|
6866
|
+
`path=${info.path} consecutiveFailures=${info.consecutiveFailures}` +
|
|
6867
|
+
`${info.error != null ? ` error=${info.error}` : ''}\n`,
|
|
6868
|
+
)
|
|
6869
|
+
},
|
|
6712
6870
|
})
|
|
6713
|
-
|
|
6871
|
+
|
|
6872
|
+
// #2789 A: the in-memory cap eviction used to silently drop the oldest
|
|
6873
|
+
// buffered inbound within a live session (the durable spool copy only
|
|
6874
|
+
// replays at boot / escalates after 15 min). Surface it as a coalesced,
|
|
6875
|
+
// per-chat "messages deferred" notice so the eviction is visible — the
|
|
6876
|
+
// evicted message still lives in the spool, so it is deferred, not lost.
|
|
6877
|
+
const EVICT_NOTICE_COOLDOWN_MS = 5 * 60 * 1000
|
|
6878
|
+
const evictNoticeByChat = new Map<string, number>()
|
|
6879
|
+
const pendingInboundBuffer = createPendingInboundBuffer({
|
|
6880
|
+
spool: inboundSpool,
|
|
6881
|
+
onEvict: (_agent, evicted) => {
|
|
6882
|
+
const chat = evicted.chatId
|
|
6883
|
+
const evThread =
|
|
6884
|
+
typeof evicted.meta?.threadId === 'string' && evicted.meta.threadId
|
|
6885
|
+
? Number(evicted.meta.threadId)
|
|
6886
|
+
: undefined
|
|
6887
|
+
const key = `${chat}:${evThread ?? '-'}`
|
|
6888
|
+
const last = evictNoticeByChat.get(key)
|
|
6889
|
+
const nowMs = Date.now()
|
|
6890
|
+
// Coalesce: at most one deferral notice per chat per cooldown so a
|
|
6891
|
+
// sustained >32 burst posts one line, not one per evicted message.
|
|
6892
|
+
if (last != null && nowMs - last < EVICT_NOTICE_COOLDOWN_MS) return
|
|
6893
|
+
evictNoticeByChat.set(key, nowMs)
|
|
6894
|
+
const threadOpts = evThread != null ? { message_thread_id: evThread } : {}
|
|
6895
|
+
// Honesty: the evicted message is NOT re-pushed into the buffer
|
|
6896
|
+
// in-session — its only in-session resolution is the escalation sweep
|
|
6897
|
+
// (sweepEscalations, ~15 min), which either delivers it or posts the
|
|
6898
|
+
// "couldn't deliver … please resend" retraction. So this notice must
|
|
6899
|
+
// NOT promise a prompt pickup ("shortly"), or it would contradict a
|
|
6900
|
+
// later escalation. Word it to match that reality: saved, handled when
|
|
6901
|
+
// the current turn frees up, and prompted-to-resend if it can't be.
|
|
6902
|
+
void swallowingApiCall(
|
|
6903
|
+
() =>
|
|
6904
|
+
bot.api.sendMessage(
|
|
6905
|
+
chat,
|
|
6906
|
+
"⏳ Messages are arriving faster than I can process them. Your messages are saved and will be handled once I finish the current turn — if any can't be picked up, I'll ask you to resend it.",
|
|
6907
|
+
{ ...threadOpts },
|
|
6908
|
+
),
|
|
6909
|
+
{ chat_id: chat, verb: 'inbound-buffer-eviction' },
|
|
6910
|
+
)
|
|
6911
|
+
},
|
|
6912
|
+
})
|
|
6714
6913
|
|
|
6715
6914
|
// PR2 obligation-ledger idle sweep. Re-present an OPEN obligation only at a
|
|
6716
6915
|
// CLEAN idle: no turn in flight AND the inbound buffer is empty — so the
|
|
@@ -6880,6 +7079,22 @@ function obligationSweep(): void {
|
|
|
6880
7079
|
// normal close path) — close silently instead of alarming the user with a
|
|
6881
7080
|
// false "I may have missed this". This is Fix 4: escalate only on knowledge,
|
|
6882
7081
|
// not doubt. Fall back to false (safe: never suppresses) if history unavailable.
|
|
7082
|
+
//
|
|
7083
|
+
// #2788 Gap A — bridge-flap gate. The escalate branch DIRECT-SENDS (via
|
|
7084
|
+
// bot.api.sendMessage below), bypassing the bridge. So while the bridge is
|
|
7085
|
+
// down the represent branch is naturally stranded (bridge → buffer) but this
|
|
7086
|
+
// branch would still fire a false "I may have missed this", even though the
|
|
7087
|
+
// real reply is merely queued behind a transient outage. Defer: if the bridge
|
|
7088
|
+
// is not alive, leave the obligation OPEN and re-drive on a later sweep once
|
|
7089
|
+
// it recovers. Obligations survive bridge death (durable ledger, re-evaluated
|
|
7090
|
+
// every sweep), so this deferral adds NO unbounded liveness dependency — the
|
|
7091
|
+
// whole gateway already rests on the bridge eventually reconnecting.
|
|
7092
|
+
if (shouldDeferEscalationForBridge({ bridgeAlive: ipcServer.getClient(agent)?.isAlive() === true })) {
|
|
7093
|
+
process.stderr.write(
|
|
7094
|
+
`telegram gateway: obligation escalation deferred — bridge down (nudge waits for reconnect) origin=${o.originTurnId}\n`,
|
|
7095
|
+
)
|
|
7096
|
+
return
|
|
7097
|
+
}
|
|
6883
7098
|
if (HISTORY_ENABLED && hasOutboundDeliveredSince(o.chatId, o.openedAt, o.threadId)) {
|
|
6884
7099
|
process.stderr.write(
|
|
6885
7100
|
`telegram gateway: obligation closed silently — outbound delivered since open origin=${o.originTurnId}\n`,
|
|
@@ -6938,6 +7153,25 @@ if (bootResumeInbound != null) {
|
|
|
6938
7153
|
} else {
|
|
6939
7154
|
pendingInboundBuffer.push(bootResumeInbound.agent, bootResumeInbound.msg)
|
|
6940
7155
|
}
|
|
7156
|
+
// At-most-once resume (#2793 part A): now that the resume inbound is
|
|
7157
|
+
// DURABLY committed (spooled, or buffered in STATIC mode), stamp the
|
|
7158
|
+
// turn's `resumed_at` so a later restart can't re-mint a fresh resume for
|
|
7159
|
+
// the same turn and re-run side effects that already executed. This is
|
|
7160
|
+
// synchronous and runs before any async delivery/ack can interleave, so
|
|
7161
|
+
// the ledger is set before the spool entry can be consumed — closing the
|
|
7162
|
+
// accept-vs-consume double-execution window. Stamping AFTER the durable
|
|
7163
|
+
// put (never before) means a crash in between leaves the turn un-stamped
|
|
7164
|
+
// and the resume is retried, not silently dropped.
|
|
7165
|
+
const resumeTurnKey = bootResumeInbound.msg.meta?.resume_turn_key
|
|
7166
|
+
if (turnsDb != null && typeof resumeTurnKey === 'string' && resumeTurnKey.length > 0) {
|
|
7167
|
+
try {
|
|
7168
|
+
markTurnResumed(turnsDb, resumeTurnKey)
|
|
7169
|
+
} catch (err) {
|
|
7170
|
+
process.stderr.write(
|
|
7171
|
+
`telegram gateway: markTurnResumed failed turnKey=${resumeTurnKey}: ${(err as Error).message}\n`,
|
|
7172
|
+
)
|
|
7173
|
+
}
|
|
7174
|
+
}
|
|
6941
7175
|
}
|
|
6942
7176
|
// Boot-replay: re-queue every un-acked spooled inbound into the
|
|
6943
7177
|
// in-memory buffer so the existing drain triggers (onClientRegistered
|
|
@@ -7272,6 +7506,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7272
7506
|
activeReactionMsgIds,
|
|
7273
7507
|
activeTurnStartedAt,
|
|
7274
7508
|
claudeBusyKeys,
|
|
7509
|
+
claudeBusyKeySince,
|
|
7275
7510
|
activeDraftStreams,
|
|
7276
7511
|
clearActiveReactions: () => {
|
|
7277
7512
|
const ad = resolveAgentDirFromEnv()
|
|
@@ -7328,11 +7563,10 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7328
7563
|
// compaction occupancy read (see maybeProactiveCompact).
|
|
7329
7564
|
if (msg.activeFile) lastSessionActiveFile = msg.activeFile
|
|
7330
7565
|
const ev = msg.event as unknown as SessionEvent
|
|
7331
|
-
//
|
|
7332
|
-
//
|
|
7333
|
-
|
|
7334
|
-
|
|
7335
|
-
progressDriver?.ingest(ev, chatHint, threadHint)
|
|
7566
|
+
// #1122/#1126: session events used to be ingested into the pinned progress
|
|
7567
|
+
// card here (`progressDriver.ingest`). The card is retired and the driver
|
|
7568
|
+
// is permanently null, so that call was a dead no-op — removed. Session
|
|
7569
|
+
// events still drive the live surfaces via `handleSessionEvent`.
|
|
7336
7570
|
handleSessionEvent(ev)
|
|
7337
7571
|
// Problem B: keep the deferred-interrupt boundary tracker in lockstep with
|
|
7338
7572
|
// the session stream (tool_use opens, tool_result/turn_end close). If a `!`
|
|
@@ -7498,7 +7732,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7498
7732
|
// found"), re-send thread-less into the main chat so the card still
|
|
7499
7733
|
// ARRIVES rather than vanishing → 10-min TTL auto-deny → wedge.
|
|
7500
7734
|
// allow-raw-bot-api: wrapped in retryWithThreadFallback (retry policy); topic-aware send
|
|
7501
|
-
void retryWithThreadFallback<{ message_id: number }>(
|
|
7735
|
+
void retryWithThreadFallback<{ message_id: number; message_thread_id?: number }>(
|
|
7502
7736
|
robustApiCall,
|
|
7503
7737
|
(tid) =>
|
|
7504
7738
|
bot.api.sendRichMessage(chatId, richMessage(text), {
|
|
@@ -7514,7 +7748,19 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
7514
7748
|
// guard the lookup.
|
|
7515
7749
|
const pend = pendingPermissions.get(requestId)
|
|
7516
7750
|
if (pend && sent && typeof sent.message_id === 'number') {
|
|
7517
|
-
|
|
7751
|
+
// #2787: record where the card ACTUALLY LANDED so the confirm sweep
|
|
7752
|
+
// scopes its re-delivery suspension to the real chat/topic. When
|
|
7753
|
+
// retryWithThreadFallback hit THREAD_NOT_FOUND (stale/renumbered
|
|
7754
|
+
// topic) it re-sent thread-less into the main chat — the returned
|
|
7755
|
+
// Message then carries no message_thread_id, so we must record the
|
|
7756
|
+
// landed topic (undefined → suspend by bare chatId), NOT the stale
|
|
7757
|
+
// requested `threadId`. Keying suspension on the stale topic would
|
|
7758
|
+
// leave the main-chat card unsuspended (a re-deliver could clobber
|
|
7759
|
+
// the live card) while needlessly suspending a topic that holds
|
|
7760
|
+
// nothing. `sent.message_thread_id` reflects reality in all three
|
|
7761
|
+
// cases: topic success → tid, main-chat / fallback → undefined.
|
|
7762
|
+
const landedThreadId = sent.message_thread_id ?? undefined
|
|
7763
|
+
pend.cards.push({ chatId, messageId: sent.message_id, threadId: landedThreadId })
|
|
7518
7764
|
permCardStore.add({
|
|
7519
7765
|
requestId,
|
|
7520
7766
|
chatId,
|
|
@@ -8374,7 +8620,7 @@ if (!STATIC) {
|
|
|
8374
8620
|
// promise EXPLICITLY (honest failure) instead of letting it sit
|
|
8375
8621
|
// forever. This is what makes the guarantee deterministic: every
|
|
8376
8622
|
// queued message ends either delivered or visibly retracted.
|
|
8377
|
-
inboundSpool?.sweepEscalations((e, { postNotice }) => {
|
|
8623
|
+
inboundSpool?.sweepEscalations((e, { postNotice, droppedCount }) => {
|
|
8378
8624
|
const chat = e.msg.chatId
|
|
8379
8625
|
const escThread =
|
|
8380
8626
|
typeof e.msg.meta?.threadId === 'string' && e.msg.meta.threadId
|
|
@@ -8393,13 +8639,17 @@ if (!STATIC) {
|
|
|
8393
8639
|
// outage that re-ages a synthetic into the bound every 15 min posts ONE
|
|
8394
8640
|
// notice, not one per cycle (the 2026-06-09 marko "please resend" spam).
|
|
8395
8641
|
if (!postNotice) return
|
|
8642
|
+
// #2789 C: report the REAL number of dropped messages for this chat
|
|
8643
|
+
// in this sweep rather than under-counting a multi-message drop as a
|
|
8644
|
+
// single one.
|
|
8645
|
+
const n = droppedCount > 0 ? droppedCount : 1
|
|
8646
|
+
const noticeText =
|
|
8647
|
+
n === 1
|
|
8648
|
+
? "⚠️ I couldn't deliver an earlier message to the agent after repeated retries (it survived restarts but the agent never picked it up). Please resend it."
|
|
8649
|
+
: `⚠️ I couldn't deliver ${n} earlier messages to the agent after repeated retries (they survived restarts but the agent never picked them up). Please resend them.`
|
|
8396
8650
|
void swallowingApiCall(
|
|
8397
8651
|
() =>
|
|
8398
|
-
bot.api.sendMessage(
|
|
8399
|
-
chat,
|
|
8400
|
-
"⚠️ I couldn't deliver an earlier message to the agent after repeated retries (it survived restarts but the agent never picked it up). Please resend it.",
|
|
8401
|
-
{ ...threadOpts },
|
|
8402
|
-
),
|
|
8652
|
+
bot.api.sendMessage(chat, noticeText, { ...threadOpts }),
|
|
8403
8653
|
{ chat_id: chat, verb: 'inbound-spool-escalation' },
|
|
8404
8654
|
)
|
|
8405
8655
|
})
|
|
@@ -8411,7 +8661,7 @@ if (!STATIC) {
|
|
|
8411
8661
|
/** Allowlisted tool names that bridges may invoke via IPC. Prevents a rogue
|
|
8412
8662
|
* bridge from calling arbitrary functions by name. */
|
|
8413
8663
|
const ALLOWED_TOOLS = new Set([
|
|
8414
|
-
'reply', '
|
|
8664
|
+
'reply', 'progress_update', 'react', 'download_attachment',
|
|
8415
8665
|
'edit_message', 'send_typing', 'pin_message', 'delete_message',
|
|
8416
8666
|
'forward_message', 'get_recent_messages',
|
|
8417
8667
|
'send_checklist', 'update_checklist',
|
|
@@ -8432,8 +8682,6 @@ async function executeToolCall(tool: string, args: Record<string, unknown>): Pro
|
|
|
8432
8682
|
switch (tool) {
|
|
8433
8683
|
case 'reply':
|
|
8434
8684
|
return executeReply(args)
|
|
8435
|
-
case 'stream_reply':
|
|
8436
|
-
return executeStreamReply(args)
|
|
8437
8685
|
case 'progress_update':
|
|
8438
8686
|
return executeProgressUpdate(args)
|
|
8439
8687
|
case 'react':
|
|
@@ -9010,7 +9258,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
9010
9258
|
// delivery-channel decision must NOT pollute final-answer CLASSIFICATION: a
|
|
9011
9259
|
// final answer the model intended to ping is STILL the final answer even when
|
|
9012
9260
|
// the framework silences the actual ping. Classify on the model's original
|
|
9013
|
-
// intent (what
|
|
9261
|
+
// intent (what executeReply already does), so an over-ping-silenced
|
|
9014
9262
|
// final answer sets finalAnswerDelivered=true — fixing both a spurious
|
|
9015
9263
|
// silent-end re-prompt and a false 'undelivered' (😐) terminal reaction.
|
|
9016
9264
|
const modelDisableNotification = args.disable_notification === true
|
|
@@ -10106,441 +10354,6 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
10106
10354
|
return { content: [{ type: 'text', text: result }] }
|
|
10107
10355
|
}
|
|
10108
10356
|
|
|
10109
|
-
async function executeStreamReply(args: Record<string, unknown>): Promise<unknown> {
|
|
10110
|
-
// #1664 — pin the turn at entry; see executeReply for the rationale.
|
|
10111
|
-
const turn = currentTurn
|
|
10112
|
-
if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
|
|
10113
|
-
if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
|
|
10114
|
-
// chat_id allowlist fallback — mirrors executeReply (~8725). stream_reply is
|
|
10115
|
-
// the primary final-answer path, so a wrong/late chat_id (int/string mismatch,
|
|
10116
|
-
// or the model echoing the wrong identifier after a silence poke flipped
|
|
10117
|
-
// currentTurn to null) would otherwise fail assertAllowedChat in the stream
|
|
10118
|
-
// controller → an invisible reply. Rewrite args.chat_id in place so every
|
|
10119
|
-
// downstream consumer (origin resolution, dedup key, the send) sees the
|
|
10120
|
-
// corrected value. Tier 1: live turn's sessionChatId. Tier 2: last-known
|
|
10121
|
-
// turn's chat (survives silence poke — Bug D fix; currentTurn is null).
|
|
10122
|
-
{
|
|
10123
|
-
const _rawChatId = String(args.chat_id ?? '')
|
|
10124
|
-
const resolved = resolveChatIdFallback(
|
|
10125
|
-
_rawChatId,
|
|
10126
|
-
loadAccess(),
|
|
10127
|
-
turn?.sessionChatId,
|
|
10128
|
-
lastActiveTurnChatId,
|
|
10129
|
-
turn != null,
|
|
10130
|
-
)
|
|
10131
|
-
if (resolved.tier !== 'raw') {
|
|
10132
|
-
process.stderr.write(
|
|
10133
|
-
`telegram gateway: stream_reply: model passed chat_id "${_rawChatId}" (not allowlisted) — ` +
|
|
10134
|
-
`routing to ${resolved.tier} turn chat "${resolved.chatId}"\n`,
|
|
10135
|
-
)
|
|
10136
|
-
args.chat_id = resolved.chatId
|
|
10137
|
-
}
|
|
10138
|
-
}
|
|
10139
|
-
// Thread precedence (matches executeReply; component 3 — turn-origin
|
|
10140
|
-
// routing): when the model passes no explicit message_thread_id, inject
|
|
10141
|
-
// the ORIGIN turn's thread (matched by origin_turn_id) — authoritative
|
|
10142
|
-
// even after currentTurn flips — falling back to the live turn's thread
|
|
10143
|
-
// when no origin is resolvable (legacy #1664). Injecting into
|
|
10144
|
-
// args.message_thread_id threads every downstream consumer consistently
|
|
10145
|
-
// (dedup key, voice-scrub metric, draft transport, the send), so a
|
|
10146
|
-
// streamed handback/synthetic-turn reply lands in the right supergroup
|
|
10147
|
-
// topic and a late stream-reply can't be stolen by a successor turn. DM:
|
|
10148
|
-
// every tier undefined → unchanged. Kill switch off → legacy live-turn
|
|
10149
|
-
// injection only.
|
|
10150
|
-
// Origin resolution is hoisted UNCONDITIONALLY (outside the
|
|
10151
|
-
// message_thread_id==null guard below) so the obligation-close path has
|
|
10152
|
-
// the correct routedOriginTurn even when the model explicitly passes
|
|
10153
|
-
// message_thread_id (forum-topic streams). Without this hoist, Fix 1
|
|
10154
|
-
// is a no-op for forum-topic streams — the origin is never resolved and
|
|
10155
|
-
// closeObligationOnSubstantiveReply falls through to the live-turn
|
|
10156
|
-
// fallback. Matches executeReply's unconditional resolution. Thread
|
|
10157
|
-
// injection still stays scoped to the message_thread_id==null branch —
|
|
10158
|
-
// only the obligation-close input changes.
|
|
10159
|
-
let streamRoutedOriginTurn: CurrentTurn | null = null
|
|
10160
|
-
// Track whether the origin was found via echo (for the routing log below).
|
|
10161
|
-
let streamOriginVia: 'echo' | 'quoted' | null = null
|
|
10162
|
-
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
10163
|
-
// Origin precedence: model echo first, then the framework-owned quoted
|
|
10164
|
-
// message_id as a deterministic fallback (mirrors executeReply).
|
|
10165
|
-
const echoedTurn = findTurnByOriginId(args.origin_turn_id as string | undefined)
|
|
10166
|
-
const quotedTurn =
|
|
10167
|
-
echoedTurn == null ? findTurnByQuotedMessageId(String(args.chat_id), args.reply_to) : null
|
|
10168
|
-
const originTurn = echoedTurn ?? quotedTurn
|
|
10169
|
-
streamRoutedOriginTurn = originTurn ?? null
|
|
10170
|
-
streamOriginVia = originTurn == null ? null : echoedTurn != null ? 'echo' : 'quoted'
|
|
10171
|
-
}
|
|
10172
|
-
if (args.message_thread_id == null) {
|
|
10173
|
-
let injected: number | undefined
|
|
10174
|
-
if (TURN_ORIGIN_ROUTING_ENABLED) {
|
|
10175
|
-
injected = resolveAnswerThreadWithLog(
|
|
10176
|
-
String(args.chat_id),
|
|
10177
|
-
undefined,
|
|
10178
|
-
streamRoutedOriginTurn,
|
|
10179
|
-
streamOriginVia,
|
|
10180
|
-
turn,
|
|
10181
|
-
'stream_reply',
|
|
10182
|
-
)
|
|
10183
|
-
} else {
|
|
10184
|
-
injected = turn?.sessionThreadId
|
|
10185
|
-
}
|
|
10186
|
-
if (injected != null) args.message_thread_id = String(injected)
|
|
10187
|
-
}
|
|
10188
|
-
|
|
10189
|
-
// Outbound secret scrub (#2044): mask before the dedup key, the draft
|
|
10190
|
-
// stream sends, and the history record. stream_reply carries the FULL
|
|
10191
|
-
// text-so-far on every call, so redacting each call keeps the answer-
|
|
10192
|
-
// stream's incremental diffing comparing redacted-against-redacted.
|
|
10193
|
-
args.text = redactOutboundText(args.text as string, 'stream_reply')
|
|
10194
|
-
|
|
10195
|
-
// Voice scrub (PR #1683 follow-up). Modern Claude on the fleet
|
|
10196
|
-
// uses the answer-stream / draft-stream path for multi-paragraph
|
|
10197
|
-
// replies — the model emits via stream_reply and the original
|
|
10198
|
-
// PR #1683 scrub site (executeReply) never sees the text. klanker's
|
|
10199
|
-
// 2026-05-24 log showed model output with em-dashes routed via
|
|
10200
|
-
// stream_reply done=true, materializing as sendMessage with no
|
|
10201
|
-
// scrub. Mirror the executeReply pattern here: scrub BEFORE the
|
|
10202
|
-
// outbound-dedup check (so retries see the scrubbed key) and
|
|
10203
|
-
// mutate args.text so all downstream consumers (the stream-
|
|
10204
|
-
// controller, dedup record, history record) see the scrubbed
|
|
10205
|
-
// version. Kill switch: SWITCHROOM_DISABLE_VOICE_SCRUB.
|
|
10206
|
-
{
|
|
10207
|
-
// Cross-path consistency (#2755): normalizePunctuation runs BEFORE
|
|
10208
|
-
// scrubVoice here, matching the reply/edit paths, so a spaced em-dash
|
|
10209
|
-
// gets the same comma treatment on every outbound path (scrubVoice
|
|
10210
|
-
// would otherwise see the raw dash first on this path only and apply
|
|
10211
|
-
// its period substitution). handleStreamReply's own deps run
|
|
10212
|
-
// normalizePunctuation again downstream — it is idempotent, so the
|
|
10213
|
-
// second pass is a no-op. Side effect: scrubVoice's dash telemetry
|
|
10214
|
-
// (voice_scrub_applied) drops to ~zero on this path since the dashes
|
|
10215
|
-
// are consumed here first — deliberate.
|
|
10216
|
-
args.text = normalizePunctuation(args.text as string)
|
|
10217
|
-
const scrub = scrubVoice(args.text as string)
|
|
10218
|
-
if (scrub.replaced > 0) {
|
|
10219
|
-
args.text = scrub.scrubbed
|
|
10220
|
-
emitRuntimeMetric({
|
|
10221
|
-
kind: 'voice_scrub_applied',
|
|
10222
|
-
chatKey: statusKey(String(args.chat_id ?? ''), args.message_thread_id != null
|
|
10223
|
-
? Number(args.message_thread_id) : undefined),
|
|
10224
|
-
replaced: scrub.replaced,
|
|
10225
|
-
site: 'stream_reply',
|
|
10226
|
-
})
|
|
10227
|
-
}
|
|
10228
|
-
}
|
|
10229
|
-
|
|
10230
|
-
// #546 dedup check: stream_reply done=true is the most-common
|
|
10231
|
-
// retry shape — claude-code re-emits the final-text call when
|
|
10232
|
-
// the previous bridge missed the ack. If turn-flush already sent
|
|
10233
|
-
// the same content, swallow the retry and return success.
|
|
10234
|
-
// Only check on done=true (the terminal call); intermediate
|
|
10235
|
-
// streaming chunks are progress edits, not full sends.
|
|
10236
|
-
if (args.done === true) {
|
|
10237
|
-
const sChatId = String(args.chat_id ?? '')
|
|
10238
|
-
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
10239
|
-
const sText = args.text as string
|
|
10240
|
-
const dup = outboundDedup.check(sChatId, sThreadId, sText, Date.now(), currentTurn?.registryKey ?? null)
|
|
10241
|
-
if (dup != null) {
|
|
10242
|
-
process.stderr.write(
|
|
10243
|
-
`telegram gateway: stream_reply: deduped (#546) chatId=${sChatId} ` +
|
|
10244
|
-
`ageMs=${dup.ageMs} preview=${JSON.stringify(dup.preview)}\n`,
|
|
10245
|
-
)
|
|
10246
|
-
return { content: [{ type: 'text', text: 'sent (deduped — same content sent via earlier path)' }] }
|
|
10247
|
-
}
|
|
10248
|
-
}
|
|
10249
|
-
|
|
10250
|
-
const access = loadAccess()
|
|
10251
|
-
// Detect chat type for throttle-default selection.
|
|
10252
|
-
// Private (DM) chats have positive numeric IDs; groups/channels are negative.
|
|
10253
|
-
const streamChatId = String(args.chat_id ?? '')
|
|
10254
|
-
const streamIsPrivate = isDmChatId(streamChatId)
|
|
10255
|
-
const streamIsForumTopic = args.message_thread_id != null && args.message_thread_id !== ''
|
|
10256
|
-
// Pre-allocated draft handoff removed in #553 PR 5 — draft-stream
|
|
10257
|
-
// now allocates a fresh draft id on its first send. The pre-alloc
|
|
10258
|
-
// path has been retired along with the placeholder text it was
|
|
10259
|
-
// designed to overwrite cleanly.
|
|
10260
|
-
|
|
10261
|
-
// #271: validate + namespace callback_data for stream_reply. Same
|
|
10262
|
-
// wrapping as executeReply — URL buttons pass through, callback_data
|
|
10263
|
-
// gets the `agent:` prefix so the dispatcher can route taps back to
|
|
10264
|
-
// this agent. Only attached on done=true so buttons land on the
|
|
10265
|
-
// final answer message, not on intermediate draft edits.
|
|
10266
|
-
let streamReplyMarkup: { inline_keyboard: AnyButton[][] } | undefined
|
|
10267
|
-
let streamButtonMeta: Map<string, AgentButtonMeta> | undefined
|
|
10268
|
-
const rawStreamKeyboard = args.inline_keyboard as AnyButton[][] | undefined
|
|
10269
|
-
if (rawStreamKeyboard != null && Boolean(args.done)) {
|
|
10270
|
-
const validationErrors = validateInlineKeyboard(rawStreamKeyboard)
|
|
10271
|
-
if (validationErrors.length > 0) {
|
|
10272
|
-
const summary = validationErrors
|
|
10273
|
-
.map((e) => `${e.path}.${e.field}: ${e.reason}`)
|
|
10274
|
-
.join('; ')
|
|
10275
|
-
throw new Error(`inline_keyboard validation failed: ${summary}`)
|
|
10276
|
-
}
|
|
10277
|
-
streamButtonMeta = extractAgentButtonMeta(rawStreamKeyboard)
|
|
10278
|
-
streamReplyMarkup = { inline_keyboard: wrapAgentCallbacks(rawStreamKeyboard) }
|
|
10279
|
-
}
|
|
10280
|
-
|
|
10281
|
-
// #1122 KPI: stream_reply's FIRST emit is a fresh user-visible outbound
|
|
10282
|
-
// message (subsequent calls edit the same message — no device ping).
|
|
10283
|
-
// Snapshot state BEFORE handleStreamReply to detect first-emit precisely.
|
|
10284
|
-
{
|
|
10285
|
-
const streamThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
10286
|
-
const sKeyBefore = streamKey(streamChatId, streamThreadId)
|
|
10287
|
-
if (!activeDraftStreams.has(sKeyBefore)) {
|
|
10288
|
-
const sKey = statusKey(streamChatId, streamThreadId)
|
|
10289
|
-
signalTracker.noteOutbound(sKey, Date.now())
|
|
10290
|
-
silencePoke.noteOutbound(sKey, Date.now())
|
|
10291
|
-
// PR3b-cutover: feed lastOutboundAt to the delivery machine (see
|
|
10292
|
-
// executeReply) so its TTL tick suppresses an active-turn fallback.
|
|
10293
|
-
shadowEmit({ kind: 'modelOutbound', key: sKey as _ChatKey, at: Date.now() })
|
|
10294
|
-
// #2527: emit turn_reply_timing on the first stream_reply of the turn,
|
|
10295
|
-
// mirroring the same gate in executeReply. Guards with firstTextReplyLogged
|
|
10296
|
-
// so a turn that calls reply first and stream_reply second doesn't double-emit.
|
|
10297
|
-
if (turn != null && !firstTextReplyLogged.has(sKey)) {
|
|
10298
|
-
firstTextReplyLogged.add(sKey)
|
|
10299
|
-
logStreamingEvent({
|
|
10300
|
-
kind: 'turn_reply_timing',
|
|
10301
|
-
chatId: streamChatId,
|
|
10302
|
-
threadId: streamThreadId,
|
|
10303
|
-
turnId: turn.turnId,
|
|
10304
|
-
timeToFirstTextReplyMs: Date.now() - turn.gatewayReceiveAt,
|
|
10305
|
-
})
|
|
10306
|
-
}
|
|
10307
|
-
// #1741 — see executeReply for the rationale: only a plausibly-
|
|
10308
|
-
// final stream_reply clears the silent-end state. An interim
|
|
10309
|
-
// ack via stream_reply must NOT clear; the Stop hook needs
|
|
10310
|
-
// the state to persist if turn_end fails to land.
|
|
10311
|
-
if (
|
|
10312
|
-
isFinalAnswerReply({
|
|
10313
|
-
text: (args.text as string | undefined) ?? '',
|
|
10314
|
-
disableNotification: args.disable_notification === true,
|
|
10315
|
-
done: args.done === true,
|
|
10316
|
-
})
|
|
10317
|
-
) {
|
|
10318
|
-
clearSilentEndState(sKey)
|
|
10319
|
-
}
|
|
10320
|
-
}
|
|
10321
|
-
}
|
|
10322
|
-
|
|
10323
|
-
// Lever 2 (design §9 lever 2): finalize the activity card BEFORE the stream
|
|
10324
|
-
// send so the card keeps its lower message_id and the reply is structurally
|
|
10325
|
-
// last. ONLY for a *substantive* final (a stream_reply done=true or ≥200
|
|
10326
|
-
// chars) — for a short pinging interim chunk do NOTHING (finalizing an ack
|
|
10327
|
-
// early would close → reopen → emit more, the #2141 ack-then-work feed, R3).
|
|
10328
|
-
// `clearActivitySummary` edits in place + nulls activityMessageId; the sticky
|
|
10329
|
-
// latch set here blocks any post-reply re-OPEN below the answer.
|
|
10330
|
-
if (
|
|
10331
|
-
turn != null
|
|
10332
|
-
&& isSubstantiveFinalReply({
|
|
10333
|
-
text: (args.text as string | undefined) ?? '',
|
|
10334
|
-
disableNotification: args.disable_notification === true,
|
|
10335
|
-
done: args.done === true,
|
|
10336
|
-
})
|
|
10337
|
-
) {
|
|
10338
|
-
// PR-4a: routed through the emission-authority façade (no-op delegates —
|
|
10339
|
-
// the latch-set and the finalize run exactly as before).
|
|
10340
|
-
const ea = emissionAuthorityFor(turn)
|
|
10341
|
-
ea.markSubstantiveFinalDelivered(() => {
|
|
10342
|
-
turn.finalAnswerEverDelivered = true
|
|
10343
|
-
if (turn.finalAnswerDeliveredAt == null) turn.finalAnswerDeliveredAt = Date.now()
|
|
10344
|
-
})
|
|
10345
|
-
ea.finalizeCard(() => {
|
|
10346
|
-
clearActivitySummary(turn)
|
|
10347
|
-
})
|
|
10348
|
-
}
|
|
10349
|
-
|
|
10350
|
-
const result = await handleStreamReply(
|
|
10351
|
-
{
|
|
10352
|
-
chat_id: streamChatId,
|
|
10353
|
-
text: args.text as string,
|
|
10354
|
-
done: Boolean(args.done),
|
|
10355
|
-
message_thread_id: args.message_thread_id as string | undefined,
|
|
10356
|
-
format: args.format as string | undefined,
|
|
10357
|
-
reply_to: args.reply_to as string | undefined,
|
|
10358
|
-
quote: args.quote as boolean | undefined,
|
|
10359
|
-
...(args.protect_content === true ? { protect_content: true } : {}),
|
|
10360
|
-
...(args.quote_text != null ? { quote_text: args.quote_text as string } : {}),
|
|
10361
|
-
...(streamReplyMarkup != null ? { reply_markup: streamReplyMarkup } : {}),
|
|
10362
|
-
...(args.disable_notification === true ? { disable_notification: true } : {}),
|
|
10363
|
-
},
|
|
10364
|
-
{ activeDraftStreams, suppressPtyPreview },
|
|
10365
|
-
{
|
|
10366
|
-
// grammy's Bot<Context, Api<RawApi>> has a wider api shape than the
|
|
10367
|
-
// local StreamBotApi interface, but is structurally compatible at
|
|
10368
|
-
// runtime (StreamBotApi is a subset). Cast through unknown to
|
|
10369
|
-
// bypass the structural-typing strictness.
|
|
10370
|
-
bot: lockedBot as unknown as { api: import('../stream-controller.js').StreamBotApi },
|
|
10371
|
-
retry: robustApiCall,
|
|
10372
|
-
repairEscapedWhitespace,
|
|
10373
|
-
normalizeParagraphBreaks,
|
|
10374
|
-
normalizePunctuation,
|
|
10375
|
-
stripExcessBold,
|
|
10376
|
-
addParagraphSpacers,
|
|
10377
|
-
assertAllowedChat,
|
|
10378
|
-
resolveThreadId,
|
|
10379
|
-
disableLinkPreview: access.disableLinkPreview !== false,
|
|
10380
|
-
defaultFormat: access.parseMode ?? 'html',
|
|
10381
|
-
logStreamingEvent,
|
|
10382
|
-
isPrivateChat: streamIsPrivate,
|
|
10383
|
-
isForumTopic: streamIsForumTopic,
|
|
10384
|
-
// Issue #310: deliver the outbound count bump BEFORE forceCompleteTurn
|
|
10385
|
-
// so the terminal render sees outboundDeliveredCount > 0. The handler
|
|
10386
|
-
// calls this dep in that order internally.
|
|
10387
|
-
recordOutboundDelivered: (chatId, threadId) => {
|
|
10388
|
-
progressDriver?.recordOutboundDelivered(
|
|
10389
|
-
chatId,
|
|
10390
|
-
threadId != null ? String(threadId) : undefined,
|
|
10391
|
-
)
|
|
10392
|
-
},
|
|
10393
|
-
forceCompleteTurn: (chatId, threadId) => {
|
|
10394
|
-
progressDriver?.forceCompleteTurn({
|
|
10395
|
-
chatId,
|
|
10396
|
-
threadId: threadId != null ? String(threadId) : undefined,
|
|
10397
|
-
})
|
|
10398
|
-
},
|
|
10399
|
-
historyEnabled: HISTORY_ENABLED,
|
|
10400
|
-
recordOutbound,
|
|
10401
|
-
...(HISTORY_ENABLED ? { getLatestInboundMessageId } : {}),
|
|
10402
|
-
writeError: (line) => process.stderr.write(line),
|
|
10403
|
-
// When the operator sets `channels.telegram.stream_throttle_ms` in yaml,
|
|
10404
|
-
// the env override wins; otherwise draft-stream's DM/group defaults apply
|
|
10405
|
-
// (400 ms for DMs, 1000 ms for groups). `throttleMs: undefined` passes
|
|
10406
|
-
// through to draft-stream where the per-chat-type default applies.
|
|
10407
|
-
...(STREAM_THROTTLE_MS_OVERRIDE != null ? { throttleMs: STREAM_THROTTLE_MS_OVERRIDE } : {}),
|
|
10408
|
-
progressCardActive: streamMode === 'checklist',
|
|
10409
|
-
},
|
|
10410
|
-
)
|
|
10411
|
-
// Issue #137: bump the per-turn outbound counter on every successful
|
|
10412
|
-
// stream_reply call (partial OR final). Even a single chunk landing
|
|
10413
|
-
// proves the delivery path worked. messageId may be null when the
|
|
10414
|
-
// call only updated the streaming draft and didn't sendMessage on
|
|
10415
|
-
// this invocation — that case still counts as activity.
|
|
10416
|
-
if (result.messageId != null) {
|
|
10417
|
-
try {
|
|
10418
|
-
progressDriver?.recordOutboundDelivered(
|
|
10419
|
-
String(args.chat_id ?? ''),
|
|
10420
|
-
args.message_thread_id as string | undefined,
|
|
10421
|
-
)
|
|
10422
|
-
} catch { /* best-effort signal */ }
|
|
10423
|
-
// Issue #203: stream_reply is the agent's primary reply path. Without
|
|
10424
|
-
// ticking the silent-gap tracker here, turn_signal_gap reports the
|
|
10425
|
-
// entire turn duration as silent for any turn that uses stream_reply
|
|
10426
|
-
// — which per CLAUDE.md guidance is most of them. The metric would be
|
|
10427
|
-
// worse than no metric. Tick on every successful delivery (partial or
|
|
10428
|
-
// final) so the gap measurement reflects real silent intervals.
|
|
10429
|
-
try {
|
|
10430
|
-
const threadIdNum = args.message_thread_id != null
|
|
10431
|
-
? Number(args.message_thread_id)
|
|
10432
|
-
: undefined
|
|
10433
|
-
signalTracker.noteSignal(
|
|
10434
|
-
statusKey(String(args.chat_id ?? ''), threadIdNum),
|
|
10435
|
-
Date.now(),
|
|
10436
|
-
)
|
|
10437
|
-
} catch { /* best-effort signal */ }
|
|
10438
|
-
}
|
|
10439
|
-
// #710: stash agent-button meta keyed by the final message id so the
|
|
10440
|
-
// callback handler can honor ack_text / single_use on tap.
|
|
10441
|
-
if (
|
|
10442
|
-
args.done === true
|
|
10443
|
-
&& result.messageId != null
|
|
10444
|
-
&& streamButtonMeta != null
|
|
10445
|
-
&& streamButtonMeta.size > 0
|
|
10446
|
-
) {
|
|
10447
|
-
rememberAgentButtonMeta(String(args.chat_id ?? ''), result.messageId, streamButtonMeta)
|
|
10448
|
-
}
|
|
10449
|
-
// #546 dedup record: capture the final stream_reply text on the
|
|
10450
|
-
// terminal call so a subsequent retry (different bridge, same
|
|
10451
|
-
// content) lands as a no-op instead of a second message.
|
|
10452
|
-
if (args.done === true && result.messageId != null) {
|
|
10453
|
-
const sChatId = String(args.chat_id ?? '')
|
|
10454
|
-
const sThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
|
|
10455
|
-
outboundDedup.record(sChatId, sThreadId, args.text as string, Date.now(), currentTurn?.registryKey ?? null)
|
|
10456
|
-
// #1445 cross-turn pending-async ambient. The terminal stream_reply
|
|
10457
|
-
// (done=true) is the user-visible anchor for any cross-turn wait
|
|
10458
|
-
// that follows. Capture it so if this turn ends with a pending
|
|
10459
|
-
// async dispatch, the framework edits THIS message in place at
|
|
10460
|
-
// intervals.
|
|
10461
|
-
//
|
|
10462
|
-
// #2669 — capture whether the stream-reply-handler sent literally
|
|
10463
|
-
// (format:'text') so the cross-turn edit tick re-edits with the same
|
|
10464
|
-
// shape. The default rich-markdown path re-edits via the rich path.
|
|
10465
|
-
const streamFormat = (args.format as string | undefined) ?? (access.parseMode ?? 'html')
|
|
10466
|
-
const streamLiteralText = streamFormat === 'text'
|
|
10467
|
-
// #1760 primary fix — clear any stale prior-turn ticker before
|
|
10468
|
-
// re-anchoring on stream_reply done. See the matching comment at
|
|
10469
|
-
// the executeReply finalize site.
|
|
10470
|
-
pendingProgress.clearPending(statusKey(sChatId, sThreadId), 'reply_finalize')
|
|
10471
|
-
pendingProgress.noteOutbound(statusKey(sChatId, sThreadId), {
|
|
10472
|
-
messageId: result.messageId,
|
|
10473
|
-
text: args.text as string,
|
|
10474
|
-
literalText: streamLiteralText,
|
|
10475
|
-
})
|
|
10476
|
-
}
|
|
10477
|
-
// #1664 — mark the turn's final answer as delivered. For stream_reply a
|
|
10478
|
-
// call with done=true IS the final answer by definition (the model
|
|
10479
|
-
// explicitly closed the stream). A non-terminal stream_reply chunk also
|
|
10480
|
-
// counts when it carries the final-answer signals — notification-bearing
|
|
10481
|
-
// OR substantive length — via the same `isFinalAnswerReply` predicate
|
|
10482
|
-
// executeReply uses. See the CurrentTurn.finalAnswerDelivered doc-comment
|
|
10483
|
-
// for why replyCalled is not a sufficient signal here.
|
|
10484
|
-
if (
|
|
10485
|
-
turn != null &&
|
|
10486
|
-
isFinalAnswerReply({
|
|
10487
|
-
text: (args.text as string | undefined) ?? '',
|
|
10488
|
-
disableNotification: args.disable_notification === true,
|
|
10489
|
-
done: args.done === true,
|
|
10490
|
-
})
|
|
10491
|
-
) {
|
|
10492
|
-
turn.finalAnswerDelivered = true
|
|
10493
|
-
// Feed-reopen refinement: a stream_reply done=true (or a ≥200-char
|
|
10494
|
-
// chunk) is substantive; a short pinging non-done chunk is an ack. Only
|
|
10495
|
-
// the latter should re-open the feed on subsequent post-answer work.
|
|
10496
|
-
turn.finalAnswerSubstantive = isSubstantiveFinalReply({
|
|
10497
|
-
text: (args.text as string | undefined) ?? '',
|
|
10498
|
-
disableNotification: args.disable_notification === true,
|
|
10499
|
-
done: args.done === true,
|
|
10500
|
-
})
|
|
10501
|
-
// Sticky ordering latch (lever 1): set once a SUBSTANTIVE final lands;
|
|
10502
|
-
// never cleared by reopen. The card OPEN gate keys on this sticky latch.
|
|
10503
|
-
if (turn.finalAnswerSubstantive) turn.finalAnswerEverDelivered = true
|
|
10504
|
-
if (turn.finalAnswerSubstantive && turn.finalAnswerDeliveredAt == null) turn.finalAnswerDeliveredAt = Date.now()
|
|
10505
|
-
if (turn.finalAnswerSubstantive) closeObligationOnSubstantiveReply(args, turn, streamRoutedOriginTurn)
|
|
10506
|
-
// #1744 follow-up — stream_reply edge case. The first-emit gate at
|
|
10507
|
-
// L5178 only clears silent-end state on the FIRST emit of a stream.
|
|
10508
|
-
// If a stream's first emit was ack-shaped (disable_notification:true,
|
|
10509
|
-
// short text, no done) it correctly did NOT clear the state. But a
|
|
10510
|
-
// LATER emit in the same stream may flip `done=true` or carry
|
|
10511
|
-
// substantive text — that's the real final answer landing, and the
|
|
10512
|
-
// state file must be cleared here too. clearSilentEndState is
|
|
10513
|
-
// idempotent (no-op when the file is absent or the turnKey doesn't
|
|
10514
|
-
// match), so calling it unconditionally on every final-answer-shaped
|
|
10515
|
-
// emit is safe even if the first-emit path already cleared.
|
|
10516
|
-
const streamThreadIdForClear = args.message_thread_id != null
|
|
10517
|
-
? Number(args.message_thread_id)
|
|
10518
|
-
: undefined
|
|
10519
|
-
clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear))
|
|
10520
|
-
}
|
|
10521
|
-
// v0.13.30 follow-up — release the buffer gate on every successful
|
|
10522
|
-
// stream_reply too. Same rationale as executeReply: short replies
|
|
10523
|
-
// with `disable_notification: true` would otherwise wedge the gate
|
|
10524
|
-
// forever. `result.status` is always 'updated' | 'finalized'
|
|
10525
|
-
// (stream-reply-handler.ts:305) at this point — earlier failures
|
|
10526
|
-
// throw or return before reaching here.
|
|
10527
|
-
{
|
|
10528
|
-
const sChat = String(args.chat_id ?? '')
|
|
10529
|
-
const sThread = resolveThreadId(sChat, args.message_thread_id as string | undefined)
|
|
10530
|
-
// Component 1: pass the turn (finalAnswerDelivered set above for a
|
|
10531
|
-
// final stream emit). Interim stream chunks leave it false → no
|
|
10532
|
-
// cross-topic drain until the done=true / substantive emit lands.
|
|
10533
|
-
releaseTurnBufferGate(statusKey(sChat, sThread), turn ?? undefined)
|
|
10534
|
-
// Component 5: reap the queued-status placeholder for THIS turn's
|
|
10535
|
-
// topic once the final answer landed. Key on the turn's own session
|
|
10536
|
-
// thread (where the placeholder lives), not the answer's resolved one.
|
|
10537
|
-
if (turn?.finalAnswerDelivered === true) {
|
|
10538
|
-
reapQueuedStatus(turn.sessionChatId, turn.sessionThreadId)
|
|
10539
|
-
}
|
|
10540
|
-
}
|
|
10541
|
-
return { content: [{ type: 'text', text: `${result.status} (id: ${result.messageId ?? 'pending'})` }] }
|
|
10542
|
-
}
|
|
10543
|
-
|
|
10544
10357
|
async function executeProgressUpdate(args: Record<string, unknown>): Promise<unknown> {
|
|
10545
10358
|
if (!args.chat_id) throw new Error('progress_update: chat_id is required')
|
|
10546
10359
|
if (!args.text) throw new Error('progress_update: text is required')
|
|
@@ -10552,6 +10365,14 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
|
|
|
10552
10365
|
|
|
10553
10366
|
assertAllowedChat(chat_id)
|
|
10554
10367
|
|
|
10368
|
+
// Outbound secret scrub (#2044). progress_update was the ONLY send site not
|
|
10369
|
+
// calling redactOutboundText, so a secret the agent echoed into a progress
|
|
10370
|
+
// line reached Telegram unmasked (reply/stream_reply/edit_message/turn_flush
|
|
10371
|
+
// all redact). Mask BEFORE the 300-char truncation below: a secret straddling
|
|
10372
|
+
// the 300-char cut would otherwise be sliced apart and evade the detector, so
|
|
10373
|
+
// redaction has to run on the full untruncated text first.
|
|
10374
|
+
text = redactOutboundText(text, 'progress_update')
|
|
10375
|
+
|
|
10555
10376
|
// Truncate to 300 chars
|
|
10556
10377
|
if (text.length > 300) {
|
|
10557
10378
|
text = text.slice(0, 299) + '…'
|
|
@@ -10592,44 +10413,14 @@ async function executeProgressUpdate(args: Record<string, unknown>): Promise<unk
|
|
|
10592
10413
|
progressUpdateTurnCount.set(key, currentCount + 1)
|
|
10593
10414
|
}
|
|
10594
10415
|
|
|
10595
|
-
// Issue #305 Option A —
|
|
10596
|
-
//
|
|
10597
|
-
//
|
|
10598
|
-
//
|
|
10599
|
-
//
|
|
10600
|
-
|
|
10601
|
-
|
|
10602
|
-
|
|
10603
|
-
db: turnsDb,
|
|
10604
|
-
chatId: chat_id,
|
|
10605
|
-
threadId,
|
|
10606
|
-
agentIdHint,
|
|
10607
|
-
toolUseIdHint,
|
|
10608
|
-
})
|
|
10609
|
-
if (subAgent != null && progressDriver != null) {
|
|
10610
|
-
const cardText = text.length > 200 ? text.slice(0, 199) + '…' : text
|
|
10611
|
-
const result = progressDriver.recordSubAgentNarrative({
|
|
10612
|
-
chatId: chat_id,
|
|
10613
|
-
threadId: threadId != null ? String(threadId) : undefined,
|
|
10614
|
-
agentId: subAgent.agentId,
|
|
10615
|
-
text: cardText,
|
|
10616
|
-
})
|
|
10617
|
-
if (result.ok) {
|
|
10618
|
-
progressUpdateLastSent.set(key, now)
|
|
10619
|
-
try {
|
|
10620
|
-
signalTracker.noteSignal(key, Date.now())
|
|
10621
|
-
} catch { /* best-effort signal */ }
|
|
10622
|
-
return {
|
|
10623
|
-
content: [
|
|
10624
|
-
{
|
|
10625
|
-
type: 'text',
|
|
10626
|
-
text: JSON.stringify({ ok: true, mode: 'card', agent_id: subAgent.agentId }),
|
|
10627
|
-
},
|
|
10628
|
-
],
|
|
10629
|
-
}
|
|
10630
|
-
}
|
|
10631
|
-
// Otherwise fall through to message-send below.
|
|
10632
|
-
}
|
|
10416
|
+
// Issue #305 Option A — the card-injection path (route a sub-agent's
|
|
10417
|
+
// `progress_update` narrative into its row on the parent's pinned card)
|
|
10418
|
+
// was retired with the progress card in #1122/#1126: `progressDriver` is
|
|
10419
|
+
// permanently null, so `recordSubAgentNarrative` could never fire and the
|
|
10420
|
+
// whole `resolveCallingSubagent` → card-inject block was a dead no-op that
|
|
10421
|
+
// always fell through here. Removed. Sub-agent progress now surfaces via
|
|
10422
|
+
// the `🛠 Worker` activity feed + the model's own in-voice handback, and a
|
|
10423
|
+
// `progress_update` call always takes the plain message-send path below.
|
|
10633
10424
|
|
|
10634
10425
|
// Send plain message (no quote-reply). Single rich-markdown path (#2669):
|
|
10635
10426
|
// `parseMode:'text'` config opts into a literal plain send; everything
|
|
@@ -11798,7 +11589,11 @@ function resetOrphanedReplyTimeout(): void {
|
|
|
11798
11589
|
currentSessionChatId: turn.sessionChatId,
|
|
11799
11590
|
capturedTextCount: turn.capturedText.length,
|
|
11800
11591
|
replyCalled: turn.replyCalled,
|
|
11801
|
-
|
|
11592
|
+
// #1122/#1126: the pinned card is retired and `progressDriver` is
|
|
11593
|
+
// permanently null, so this suppression signal is always false. Kept as a
|
|
11594
|
+
// literal (the shouldArm* predicate still accepts the flag) rather than
|
|
11595
|
+
// reading the dead driver.
|
|
11596
|
+
progressCardActive: false,
|
|
11802
11597
|
})) {
|
|
11803
11598
|
turn.orphanedReplyTimeoutId = setTimeout(() => {
|
|
11804
11599
|
// The timer fires asynchronously; re-read currentTurn so we
|
|
@@ -11812,7 +11607,8 @@ function resetOrphanedReplyTimeout(): void {
|
|
|
11812
11607
|
currentSessionChatId: t.sessionChatId,
|
|
11813
11608
|
capturedTextCount: t.capturedText.length,
|
|
11814
11609
|
replyCalled: t.replyCalled,
|
|
11815
|
-
|
|
11610
|
+
// #1122/#1126: retired card / null driver — always false (see above).
|
|
11611
|
+
progressCardActive: false,
|
|
11816
11612
|
})) {
|
|
11817
11613
|
// Feed-survival guard: re-arm the fuse while the turn is
|
|
11818
11614
|
// legitimately working — an in-flight tool, a detached background
|
|
@@ -12192,7 +11988,10 @@ function openLivenessFeedIfDue(turn: CurrentTurn): void {
|
|
|
12192
11988
|
const livenessHeader: SessionActivityHeader = {
|
|
12193
11989
|
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
12194
11990
|
}
|
|
12195
|
-
|
|
11991
|
+
// Liveness card is a single "step" whose start is the turn start, so `age`
|
|
11992
|
+
// IS the step's own elapsed. formatStepSuffix keeps the `→` line timer-free
|
|
11993
|
+
// until the step has run ≥ STEP_TIMER_MIN_MS (header total still shows).
|
|
11994
|
+
const rendered = renderActivityFeedWithNested(lines, [], false, formatStepSuffix(age), undefined, livenessHeader)
|
|
12196
11995
|
if (rendered == null) return
|
|
12197
11996
|
turn.activityPendingRender = rendered
|
|
12198
11997
|
const ea = emissionAuthorityFor(turn)
|
|
@@ -12312,8 +12111,10 @@ function feedHeartbeatTick(): void {
|
|
|
12312
12111
|
label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
|
|
12313
12112
|
}
|
|
12314
12113
|
const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working in background…']
|
|
12114
|
+
// `subagentAt` is the worker's last ADVANCE — the current step's start —
|
|
12115
|
+
// so this suffix is already per-step. formatStepSuffix adds the 10 s gate.
|
|
12315
12116
|
const elapsed = Date.now() - subagentAt
|
|
12316
|
-
const rendered = renderActivityFeedWithNested(lines, [], false,
|
|
12117
|
+
const rendered = renderActivityFeedWithNested(lines, [], false, formatStepSuffix(elapsed), undefined, livenessHeader)
|
|
12317
12118
|
if (rendered == null) return
|
|
12318
12119
|
turn.activityPendingRender = rendered
|
|
12319
12120
|
const ea = emissionAuthorityFor(turn)
|
|
@@ -12354,7 +12155,10 @@ function feedHeartbeatTick(): void {
|
|
|
12354
12155
|
if (turn.lastToolLabelAt == null) return // feed not driven by a labelled step
|
|
12355
12156
|
const elapsed = Date.now() - turn.lastToolLabelAt
|
|
12356
12157
|
if (elapsed < FEED_HEARTBEAT_MIN_STALE_MS) return // step is fresh; feed advancing normally
|
|
12357
|
-
|
|
12158
|
+
// `lastToolLabelAt` resets on every new tool label, so `elapsed` is the
|
|
12159
|
+
// CURRENT step's own run time. formatStepSuffix holds the timer back until
|
|
12160
|
+
// the step passes STEP_TIMER_MIN_MS (10 s) — header total is unaffected.
|
|
12161
|
+
const rendered = composeTurnActivity(turn, false, formatStepSuffix(elapsed))
|
|
12358
12162
|
if (rendered == null) return
|
|
12359
12163
|
turn.activityPendingRender = rendered
|
|
12360
12164
|
const ea = emissionAuthorityFor(turn)
|
|
@@ -12614,6 +12418,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
12614
12418
|
deliveryQueue,
|
|
12615
12419
|
chatKey(ev.chatId, ev.threadId != null ? Number(ev.threadId) : null),
|
|
12616
12420
|
ev.messageId,
|
|
12421
|
+
// #2786 — pass the raw enqueue envelope so the ack survives the
|
|
12422
|
+
// composer merging/reordering inbound wrappers (the single
|
|
12423
|
+
// re-parsed `ev.messageId` can then belong to a sibling, not our
|
|
12424
|
+
// tracked message). The tolerant match scans all ids in this
|
|
12425
|
+
// content; a synthetic-source turn still lacks the user id, so the
|
|
12426
|
+
// cross-source false-ack guard holds.
|
|
12427
|
+
ev.rawContent,
|
|
12617
12428
|
)
|
|
12618
12429
|
}
|
|
12619
12430
|
// PR3b-cutover: feed the authoritative turn-start to the delivery
|
|
@@ -13562,6 +13373,18 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13562
13373
|
|
|
13563
13374
|
if (flushDecision.kind === 'flush') {
|
|
13564
13375
|
let capturedText = flushDecision.text
|
|
13376
|
+
// #2798 — turn-flush delivers the model's terminal prose when it
|
|
13377
|
+
// skipped reply/stream_reply, but historically bypassed the reply
|
|
13378
|
+
// path's markdown normalization entirely. Mirror executeReply's front
|
|
13379
|
+
// of pipeline here so the backstop renders identically: repair LLM
|
|
13380
|
+
// JSON-escape bungles (literal `\n`), then promote lone prose paragraph
|
|
13381
|
+
// breaks into GFM hard breaks so the Bot API 10.1 rich path doesn't
|
|
13382
|
+
// collapse them (lists/tables/code left untouched). Runs BEFORE the
|
|
13383
|
+
// redact/scrub below, exactly as reply orders it (repair → normalize →
|
|
13384
|
+
// redact → scrub), so masking sees the repaired text. The matching
|
|
13385
|
+
// addParagraphSpacers pass runs on the send side just before
|
|
13386
|
+
// splitMarkdownChunks (see below).
|
|
13387
|
+
capturedText = normalizeParagraphBreaks(repairEscapedWhitespace(capturedText))
|
|
13565
13388
|
// Component 3 — origin-thread backstop. `chatId`/`threadId` are
|
|
13566
13389
|
// captured from the turn atom (turn.sessionChatId/sessionThreadId)
|
|
13567
13390
|
// at the top of this turn_end handler, NOT from the live
|
|
@@ -13581,6 +13404,15 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13581
13404
|
// the preview, and recordOutbound.
|
|
13582
13405
|
capturedText = redactOutboundText(capturedText, 'turn_flush')
|
|
13583
13406
|
|
|
13407
|
+
// #2798 reply-parity — normalize dashes/bullets and trip the over-bold
|
|
13408
|
+
// guard deterministically, on code-masked text. This is the SAME chain
|
|
13409
|
+
// the reply path applies (`stripExcessBold(normalizePunctuation(text))`)
|
|
13410
|
+
// in the SAME order: after redact, before scrubVoice. Without it the
|
|
13411
|
+
// turn-flush backstop rendered punctuation/bold differently from an
|
|
13412
|
+
// identical reply. Kept inline to mirror reply exactly — a shared helper
|
|
13413
|
+
// is a deliberate future refactor, not this change.
|
|
13414
|
+
capturedText = stripExcessBold(normalizePunctuation(capturedText))
|
|
13415
|
+
|
|
13584
13416
|
// Voice scrub (PR #1683 follow-up). Turn-flush is the path
|
|
13585
13417
|
// that fires when the model emits raw transcript text WITHOUT
|
|
13586
13418
|
// calling reply / stream_reply. That captured text bypasses
|
|
@@ -13692,7 +13524,14 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13692
13524
|
link_preview_options: { is_disabled: true },
|
|
13693
13525
|
}
|
|
13694
13526
|
const limit = RICH_MESSAGE_MAX_CHARS
|
|
13695
|
-
|
|
13527
|
+
// #2798 / #2692 — inject visible blank-line spacers into prose `\n\n`
|
|
13528
|
+
// gaps before splitting, exactly as executeReply does. The rich GFM
|
|
13529
|
+
// renderer collapses a bare `\n\n` gap TIGHT, so without this the
|
|
13530
|
+
// paragraph boundaries from the '\n\n' block join (turn-flush-safety
|
|
13531
|
+
// .ts) would still render jammed together. Mirrors reply's
|
|
13532
|
+
// `addParagraphSpacers(text)` on the non-literal path.
|
|
13533
|
+
const renderedText = addParagraphSpacers(capturedText)
|
|
13534
|
+
const htmlChunks = splitMarkdownChunks(renderedText, limit)
|
|
13696
13535
|
const sentIds: number[] = []
|
|
13697
13536
|
try {
|
|
13698
13537
|
// #654 deterministic double-message fix. If the progress
|
|
@@ -13969,12 +13808,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
13969
13808
|
}
|
|
13970
13809
|
// #550: symmetric cleanup with the writeTurnActiveMarker call at
|
|
13971
13810
|
// the enqueue arm (line ~2810). Pre-fix, removal was single-pathed
|
|
13972
|
-
// through progressDriver.onTurnComplete
|
|
13973
|
-
//
|
|
13974
|
-
// marker across restarts and triggering
|
|
13975
|
-
// restarts.
|
|
13976
|
-
//
|
|
13977
|
-
//
|
|
13811
|
+
// through the (now-retired, #1122/#1126) progressDriver.onTurnComplete
|
|
13812
|
+
// callback, which silently no-op'd when forceCompleteTurn found no
|
|
13813
|
+
// active card — leaking the marker across restarts and triggering
|
|
13814
|
+
// watchdog false-positive restarts. That driver callback no longer
|
|
13815
|
+
// exists (progressDriver is permanently null); this explicit
|
|
13816
|
+
// removeTurnActiveMarker is now the sole cleanup path (idempotent —
|
|
13817
|
+
// unlinkSync swallows ENOENT).
|
|
13978
13818
|
removeTurnActiveMarker(STATE_DIR)
|
|
13979
13819
|
// #1067: null the atom in one assignment, replacing the seven
|
|
13980
13820
|
// field clears the pre-refactor version did. Any late-arriving
|
|
@@ -14651,36 +14491,56 @@ async function handleInbound(
|
|
|
14651
14491
|
}
|
|
14652
14492
|
|
|
14653
14493
|
// Issue #109: when the user has to ask "status?" mid-turn, the live progress
|
|
14654
|
-
// surface (
|
|
14655
|
-
// event with a snapshot of
|
|
14656
|
-
// frequency. We don't intercept the
|
|
14657
|
-
// the agent, since the agent may have a
|
|
14658
|
-
//
|
|
14494
|
+
// surface (status reactions + the edit-in-place `🛠 Worker` feed) may have
|
|
14495
|
+
// failed its job. Log the event with a snapshot of what is ACTUALLY in
|
|
14496
|
+
// flight so we can count + analyze frequency. We don't intercept the
|
|
14497
|
+
// message — it still flows through to the agent, since the agent may have a
|
|
14498
|
+
// useful answer the surfaces haven't communicated yet.
|
|
14499
|
+
//
|
|
14500
|
+
// Truthful telemetry (was a lying diagnostic): the pinned progress card was
|
|
14501
|
+
// retired in #1122/#1126 and `progressDriver` is now permanently null, so
|
|
14502
|
+
// the old `progressDriver.peek(...)` snapshot ALWAYS reported idle/zero
|
|
14503
|
+
// regardless of live work — actively misleading debugging. We now read the
|
|
14504
|
+
// LIVE surfaces instead: the running-background-worker DB count
|
|
14505
|
+
// (`countRunningWorkers`), the `🛠 Worker` activity-feed size, the
|
|
14506
|
+
// cross-turn pending-async-dispatch flag (`pending-work-progress.ts`), and
|
|
14507
|
+
// whether a turn is active (`activeTurnStartedAt`). A live background worker
|
|
14508
|
+
// ⇒ non-idle. A `ux-failure` is emitted ONLY when all of those are genuinely
|
|
14509
|
+
// zero AND no turn is active — i.e. the user asked "status?" and there really
|
|
14510
|
+
// was nothing in flight for the surfaces to have shown.
|
|
14659
14511
|
//
|
|
14660
|
-
// Log shape (grep anchor: "
|
|
14661
|
-
//
|
|
14662
|
-
//
|
|
14663
|
-
//
|
|
14512
|
+
// Log shape (grep anchor: "status-query"):
|
|
14513
|
+
// status-query agent=<n> chat_id=<n> thread=<n|none> stage=<stage>
|
|
14514
|
+
// turn_age_s=<int> running_workers=<n> worker_feed=<n>
|
|
14515
|
+
// pending_async=<0|1>
|
|
14516
|
+
// When idle, a second line carries the grep anchor "ux-failure: status-query".
|
|
14664
14517
|
//
|
|
14665
|
-
// `
|
|
14666
|
-
//
|
|
14667
|
-
//
|
|
14668
|
-
// string-comparison branches.
|
|
14518
|
+
// `turn_age_s` is always a non-negative integer; `-1` is the sentinel for
|
|
14519
|
+
// "no active turn" so structured-log parsers (Loki, Datadog, awk) can treat
|
|
14520
|
+
// the field as numeric without string-comparison branches.
|
|
14669
14521
|
if (STATUS_QUERY_RE.test(text)) {
|
|
14670
14522
|
try {
|
|
14671
|
-
const
|
|
14672
|
-
const
|
|
14673
|
-
const
|
|
14674
|
-
|
|
14675
|
-
|
|
14676
|
-
|
|
14677
|
-
|
|
14678
|
-
|
|
14523
|
+
const thread = messageThreadId != null ? String(messageThreadId) : 'none'
|
|
14524
|
+
const key = statusKey(chat_id, messageThreadId)
|
|
14525
|
+
const turnStartedAt = activeTurnStartedAt.get(key)
|
|
14526
|
+
const turnActive = turnStartedAt != null
|
|
14527
|
+
const surfaces: StatusQuerySurfaces = {
|
|
14528
|
+
turnActive,
|
|
14529
|
+
turnAgeS: turnActive
|
|
14530
|
+
? Math.max(0, Math.floor((Date.now() - turnStartedAt) / 1000))
|
|
14531
|
+
: -1,
|
|
14532
|
+
runningWorkers: countRunningWorkers(),
|
|
14533
|
+
workerFeedSize: workerActivityFeed?.size ?? 0,
|
|
14534
|
+
pendingAsync: pendingProgress.hasPendingAsyncDispatch(key) ? 1 : 0,
|
|
14535
|
+
}
|
|
14536
|
+
const telemetry = deriveStatusQueryTelemetry(surfaces)
|
|
14679
14537
|
const agentName = process.env.SWITCHROOM_AGENT_NAME ?? '-'
|
|
14680
14538
|
process.stderr.write(
|
|
14681
|
-
|
|
14682
|
-
`card_stage=${stage} card_turn_age_s=${turnAgeS} card_items=${itemCount} card_subagents=${subAgentCount}\n`,
|
|
14539
|
+
formatStatusQueryLine(agentName, String(chat_id), thread, surfaces, telemetry),
|
|
14683
14540
|
)
|
|
14541
|
+
if (telemetry.idle) {
|
|
14542
|
+
process.stderr.write(formatStatusQueryUxFailureLine(agentName, String(chat_id), thread))
|
|
14543
|
+
}
|
|
14684
14544
|
} catch (err) {
|
|
14685
14545
|
process.stderr.write(`telegram gateway: status-query telemetry failed: ${(err as Error).message}\n`)
|
|
14686
14546
|
}
|
|
@@ -18294,6 +18154,17 @@ async function refreshPinnedBanner(reason: string): Promise<void> {
|
|
|
18294
18154
|
onError: (phase, err) => {
|
|
18295
18155
|
process.stderr.write(`telegram gateway: banner ${phase} failed (${reason}): ${err}\n`)
|
|
18296
18156
|
},
|
|
18157
|
+
// Durable pin persistence into the SHARED status-pin store (distinct
|
|
18158
|
+
// `banner:` pinKey). persist-BEFORE-pin ordering: pending() lands before
|
|
18159
|
+
// the pinChatMessage call so a crash in that window is recoverable by the
|
|
18160
|
+
// one runStatusPinBootCleanup on next boot. Best-effort / gated no-op.
|
|
18161
|
+
persist: {
|
|
18162
|
+
pending: (chatId, messageId) =>
|
|
18163
|
+
persistBannerRow({ pinKey: BANNER_PIN_KEY, chatId, messageId, pending: true }),
|
|
18164
|
+
confirm: (chatId, messageId) =>
|
|
18165
|
+
persistBannerRow({ pinKey: BANNER_PIN_KEY, chatId, messageId }),
|
|
18166
|
+
clear: () => persistBannerRow(null),
|
|
18167
|
+
},
|
|
18297
18168
|
})
|
|
18298
18169
|
} catch (err) {
|
|
18299
18170
|
process.stderr.write(`telegram gateway: banner refresh error (${reason}): ${err}\n`)
|
|
@@ -24679,10 +24550,22 @@ process.on('SIGINT', () => void shutdown('SIGINT'))
|
|
|
24679
24550
|
// pacing + the silence-poke safety net. The module-level helper vars
|
|
24680
24551
|
// (`unpinProgressCardForChat`, `getPinnedProgressCardMessageId`,
|
|
24681
24552
|
// `completeProgressCardTurn`, `flushProgressCardsForShutdown`,
|
|
24682
|
-
// `progressDriver`) stay declared and `null`, so the
|
|
24553
|
+
// `progressDriver`) stay declared and `null`, so the remaining
|
|
24683
24554
|
// optional-chained call sites scattered through this file remain
|
|
24684
|
-
// valid TypeScript and no-op at runtime.
|
|
24685
|
-
//
|
|
24555
|
+
// valid TypeScript and no-op at runtime.
|
|
24556
|
+
//
|
|
24557
|
+
// Cleanup pass (truthful-telemetry PR): the actively-misleading dead sites
|
|
24558
|
+
// have been purged — the `status-query` telemetry now reads the live surfaces
|
|
24559
|
+
// instead of the null-driver `peek()`; the sub-agent `onStall`/`onUnstall`/
|
|
24560
|
+
// `onStallTerminal` gateway wirings (which falsely implied a stall renders a
|
|
24561
|
+
// visual badge) are removed; the dead sub-agent card-injection block in
|
|
24562
|
+
// `progress_update` is gone; and `progressCardActive` reads the literal false
|
|
24563
|
+
// instead of `progressDriver != null`. The remaining `progressDriver?.X` /
|
|
24564
|
+
// `unpinProgressCardForChat?.(...)` / `completeProgressCardTurn?.(...)` sites
|
|
24565
|
+
// are interface-bound no-ops (deps handed to stream-reply-handler /
|
|
24566
|
+
// disconnect-flush, or constant-folding guards inside the turn-flush backstop
|
|
24567
|
+
// hot path); removing them means changing those modules' public shapes and is
|
|
24568
|
+
// deferred so this PR stays scoped to truthful telemetry + comment fixes.
|
|
24686
24569
|
|
|
24687
24570
|
|
|
24688
24571
|
// ─── Startup ──────────────────────────────────────────────────────────────
|
|
@@ -25402,55 +25285,21 @@ void (async () => {
|
|
|
25402
25285
|
// the model's own beat-4 handback reply; the watcher's
|
|
25403
25286
|
// role here is registry liveness + the `onFinish` cue.
|
|
25404
25287
|
log: (msg) => process.stderr.write(`telegram gateway: ${msg}\n`),
|
|
25405
|
-
//
|
|
25406
|
-
//
|
|
25407
|
-
//
|
|
25408
|
-
|
|
25409
|
-
|
|
25410
|
-
|
|
25411
|
-
//
|
|
25412
|
-
//
|
|
25413
|
-
// waiting on the next render tick to recompute idle ms.
|
|
25414
|
-
onUnstall: (agentId, description) => {
|
|
25415
|
-
progressDriver?.onSubAgentUnstall?.(agentId, description)
|
|
25416
|
-
},
|
|
25417
|
-
// RFC §Bug 6: background `Agent` dispatches in some
|
|
25418
|
-
// Claude Code versions don't write a terminal
|
|
25419
|
-
// `system + turn_duration` line to the sub-agent JSONL.
|
|
25420
|
-
// The watcher detects the silent-stall window and
|
|
25421
|
-
// synthesises terminal here; we mirror it into the
|
|
25422
|
-
// progress driver as a real `sub_agent_turn_end`
|
|
25423
|
-
// SessionEvent so the deferred-completion gate
|
|
25424
|
-
// releases and the pinned card flips from 🌀 to ✅.
|
|
25288
|
+
// #1122/#1126 cleanup: the `onStall` / `onUnstall` /
|
|
25289
|
+
// `onStallTerminal` callbacks used to route into
|
|
25290
|
+
// `progressDriver.onSubAgentStall` / `onSubAgentUnstall` /
|
|
25291
|
+
// `ingest(sub_agent_turn_end)` so the pinned card could render a
|
|
25292
|
+
// ⚠️ badge and release its deferred-completion gate. That card
|
|
25293
|
+
// was deleted and `progressDriver` is permanently null, so those
|
|
25294
|
+
// wirings were dead no-ops that falsely implied a stall renders a
|
|
25295
|
+
// visual badge (it doesn't). They are removed here.
|
|
25425
25296
|
//
|
|
25426
|
-
//
|
|
25427
|
-
//
|
|
25428
|
-
|
|
25429
|
-
|
|
25430
|
-
|
|
25431
|
-
|
|
25432
|
-
if (f.fleet.has(agentId)) {
|
|
25433
|
-
progressDriver?.ingest(
|
|
25434
|
-
{ kind: 'sub_agent_turn_end', agentId },
|
|
25435
|
-
f.chatId ?? null,
|
|
25436
|
-
undefined,
|
|
25437
|
-
)
|
|
25438
|
-
return
|
|
25439
|
-
}
|
|
25440
|
-
}
|
|
25441
|
-
// No fleet entry — the driver-side state was lost
|
|
25442
|
-
// (cs.dispose ran or peek timing missed). Drop a
|
|
25443
|
-
// log line and fall through to onFinish's audit
|
|
25444
|
-
// surface; the card may already be cleaned up.
|
|
25445
|
-
process.stderr.write(
|
|
25446
|
-
`telegram gateway: subagent-watcher: onStallTerminal ${agentId} — no fleet entry to retarget; synthetic sub_agent_turn_end skipped\n`,
|
|
25447
|
-
)
|
|
25448
|
-
} catch (err) {
|
|
25449
|
-
process.stderr.write(
|
|
25450
|
-
`telegram gateway: subagent-watcher: onStallTerminal ${agentId} ingest error: ${(err as Error).message}\n`,
|
|
25451
|
-
)
|
|
25452
|
-
}
|
|
25453
|
-
},
|
|
25297
|
+
// The load-bearing completion path is NOT here: subagent-watcher's
|
|
25298
|
+
// silent-stall terminal synthesis writes the terminal registry-DB
|
|
25299
|
+
// row itself (`recordSubagentEnd`) and fires `maybySendStateTransition`
|
|
25300
|
+
// → `onFinish` (the handback) independently of `onStallTerminal`.
|
|
25301
|
+
// Re-painting a live background worker on stall/unstall is PR 2's
|
|
25302
|
+
// job (via the `🛠 Worker` activity feed, not a retired card).
|
|
25454
25303
|
// conversational-pacing beat 4 — the handback. A foreground
|
|
25455
25304
|
// sub-agent hands its result straight back as the Task tool
|
|
25456
25305
|
// result, inside the parent's own turn; the model sees it
|
|
@@ -25504,6 +25353,27 @@ void (async () => {
|
|
|
25504
25353
|
} catch { /* best-effort */ }
|
|
25505
25354
|
}
|
|
25506
25355
|
const isBackground = dispatch.isBackground
|
|
25356
|
+
// NESTED (depth-2+) worker terminal: its live status surfaced
|
|
25357
|
+
// via the worker feed (see onProgress), so finalize that card
|
|
25358
|
+
// cleanly — never leave it frozen mid-"→ step". But NO user
|
|
25359
|
+
// handback: its result returns to its DISPATCHING WORKER as
|
|
25360
|
+
// the Task tool result (the depth-1 worker folds it into its
|
|
25361
|
+
// own handback); injecting a second gateway handback would
|
|
25362
|
+
// double-report the same work.
|
|
25363
|
+
if (dispatch.isNested) {
|
|
25364
|
+
if (workerFeedEnabled) {
|
|
25365
|
+
void workerActivityFeed?.finish(agentId, {
|
|
25366
|
+
description: dispatch.feedDescription,
|
|
25367
|
+
lastTool: null,
|
|
25368
|
+
toolCount,
|
|
25369
|
+
latestSummary: resultText,
|
|
25370
|
+
elapsedMs: durationMs,
|
|
25371
|
+
state: outcome === 'failed' ? 'failed' : 'done',
|
|
25372
|
+
})
|
|
25373
|
+
reconcileWorkerPin(agentId, null, false)
|
|
25374
|
+
}
|
|
25375
|
+
return
|
|
25376
|
+
}
|
|
25507
25377
|
if (!isBackground) {
|
|
25508
25378
|
// Model A — a foreground sub-agent finished. Collapse its
|
|
25509
25379
|
// nested child block from the parent's activity draft; the
|
|
@@ -25703,7 +25573,18 @@ void (async () => {
|
|
|
25703
25573
|
dispatch = resolveWorkerFeedDispatch(getSubagentByJsonlId(turnsDb, agentId), description)
|
|
25704
25574
|
} catch { /* best-effort */ }
|
|
25705
25575
|
}
|
|
25706
|
-
|
|
25576
|
+
// A NESTED (depth-2+) worker surfaces via the worker feed
|
|
25577
|
+
// regardless of its own background flag: its "parent" is a
|
|
25578
|
+
// worker, not the gateway's live turn, so nesting it into
|
|
25579
|
+
// `currentTurn` would attribute it to an unrelated turn.
|
|
25580
|
+
const isBackground = dispatch.isBackground || dispatch.isNested
|
|
25581
|
+
// The live step line for THIS tick: the friendly tool label on
|
|
25582
|
+
// tool ticks, else the narrative. Previously the worker feed
|
|
25583
|
+
// was fed ONLY `latestSummary` (prose), so a tools-only worker
|
|
25584
|
+
// — the common case — had a card that heartbeat-edited but
|
|
25585
|
+
// never grew past "starting…" (the frozen-card symptom). The
|
|
25586
|
+
// foreground nest path below already used this precedence.
|
|
25587
|
+
const stepLine = (progressLine != null && progressLine.length > 0) ? progressLine : latestSummary
|
|
25707
25588
|
if (!isBackground) {
|
|
25708
25589
|
// Model A — a foreground sub-agent runs inside the parent's
|
|
25709
25590
|
// turn, so its live narrative nests under the parent's
|
|
@@ -25719,7 +25600,14 @@ void (async () => {
|
|
|
25719
25600
|
// the worker feed (owner-DM fallback), not into the void.
|
|
25720
25601
|
const surface = resolveSubagentStatusSurface({
|
|
25721
25602
|
isBackground: false,
|
|
25722
|
-
|
|
25603
|
+
// A ROW-LESS worker must not nest into the live turn: with
|
|
25604
|
+
// no registry row we cannot know it belongs to this turn
|
|
25605
|
+
// (it is most often a nested dispatch whose row hasn't
|
|
25606
|
+
// linked yet), and polluting the parent's card misroutes
|
|
25607
|
+
// it. Route it via the orphan worker-feed path instead —
|
|
25608
|
+
// once the row links (watcher retry, ≤ a poll tick) the
|
|
25609
|
+
// classification self-corrects.
|
|
25610
|
+
liveTurnPresent: currentTurn != null && dispatch.hasRow,
|
|
25723
25611
|
workerFeedEnabled,
|
|
25724
25612
|
orphanStatusEnabled,
|
|
25725
25613
|
})
|
|
@@ -25733,7 +25621,7 @@ void (async () => {
|
|
|
25733
25621
|
description: dispatch.feedDescription,
|
|
25734
25622
|
lastTool,
|
|
25735
25623
|
toolCount,
|
|
25736
|
-
latestSummary,
|
|
25624
|
+
latestSummary: stepLine,
|
|
25737
25625
|
elapsedMs,
|
|
25738
25626
|
state: 'running',
|
|
25739
25627
|
},
|
|
@@ -25774,9 +25662,12 @@ void (async () => {
|
|
|
25774
25662
|
narrative = []
|
|
25775
25663
|
turn.foregroundSubAgents.set(agentId, narrative)
|
|
25776
25664
|
}
|
|
25777
|
-
// Dedup
|
|
25778
|
-
// re-emits the same
|
|
25779
|
-
|
|
25665
|
+
// Dedup within the whole rolling window (mirrors the worker
|
|
25666
|
+
// feed's accumulateNarrative): the watcher re-emits the same
|
|
25667
|
+
// narrative across ticks, and a preamble + its tool label can
|
|
25668
|
+
// repeat non-adjacently (A,B,A) — both must collapse to one
|
|
25669
|
+
// ordered step.
|
|
25670
|
+
if (!narrative.includes(child)) {
|
|
25780
25671
|
narrative.push(child)
|
|
25781
25672
|
if (narrative.length > FOREGROUND_SUBAGENT_ACCUM_MAX) {
|
|
25782
25673
|
narrative.splice(0, narrative.length - FOREGROUND_SUBAGENT_ACCUM_MAX)
|
|
@@ -25868,7 +25759,10 @@ void (async () => {
|
|
|
25868
25759
|
description: dispatch.feedDescription,
|
|
25869
25760
|
lastTool,
|
|
25870
25761
|
toolCount,
|
|
25871
|
-
|
|
25762
|
+
// The tick's step line (tool label on tool ticks, prose
|
|
25763
|
+
// on text ticks) — NOT bare latestSummary, which starved
|
|
25764
|
+
// tools-only workers of steps (frozen "starting…").
|
|
25765
|
+
latestSummary: stepLine,
|
|
25872
25766
|
elapsedMs,
|
|
25873
25767
|
state: 'running',
|
|
25874
25768
|
},
|