switchroom 0.13.52 → 0.13.54
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 +399 -213
- package/dist/auth-broker/index.js +576 -237
- package/dist/cli/drive-write-pretool.mjs +28 -13
- package/dist/cli/ms-365-write-pretool.mjs +259 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3241 -1382
- package/dist/host-control/main.js +396 -276
- package/dist/vault/approvals/kernel-server.js +8266 -8142
- package/dist/vault/broker/server.js +2894 -2770
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +17 -0
- package/profiles/_shared/telegram-style.md.hbs +2 -0
- package/skills/switchroom-status/SKILL.md +8 -6
- package/telegram-plugin/chat-lock.ts +87 -19
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1283 -343
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/disconnect-flush.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +485 -72
- package/telegram-plugin/gateway/inbound-coalesce.ts +19 -6
- package/telegram-plugin/gateway/ipc-protocol.ts +37 -0
- package/telegram-plugin/gateway/ipc-server.ts +59 -0
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +314 -0
- package/telegram-plugin/gateway/ms365-write-approval.ts +335 -0
- package/telegram-plugin/stream-reply-handler.ts +10 -8
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +116 -0
- package/telegram-plugin/tests/inbound-coalesce.test.ts +20 -4
- package/telegram-plugin/tests/ipc-validator.test.ts +61 -0
- package/telegram-plugin/tests/outbound-ordering.test.ts +228 -0
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +217 -0
- package/telegram-plugin/tests/slash-command-smart-split.test.ts +115 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +65 -8
- package/telegram-plugin/typing-wrap.ts +43 -21
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +35 -0
- package/vendor/hindsight-memory/scripts/recall.py +164 -4
- package/vendor/hindsight-memory/scripts/retain.py +52 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +42 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_topic_filter.py +139 -0
- package/profiles/default/CLAUDE.md +0 -122
|
@@ -244,6 +244,7 @@ import { handleInjectCommand } from './inject-handler.js'
|
|
|
244
244
|
import { type BannerState } from '../slot-banner.js'
|
|
245
245
|
import { refreshBanner } from '../slot-banner-driver.js'
|
|
246
246
|
import { loadConfig as loadSwitchroomConfig } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
|
|
247
|
+
import { resolveOutboundTopic as resolveOutboundTopicHelper, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
|
|
247
248
|
import { readTurnUsages } from '../../src/agents/perf.js'
|
|
248
249
|
import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
|
|
249
250
|
import { nextCompactNotify, idleCompactNotifyState, type CompactNotifyState } from './compact-notify.js'
|
|
@@ -264,6 +265,7 @@ import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
|
|
|
264
265
|
|
|
265
266
|
import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js'
|
|
266
267
|
import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
268
|
+
import { handleRequestMs365Approval } from './ms365-write-approval.js'
|
|
267
269
|
import { buildDiffPreviewCard } from './diff-preview-card.js'
|
|
268
270
|
import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick } from './pending-inbound-buffer.js'
|
|
269
271
|
import { createInboundSpool } from './inbound-spool.js'
|
|
@@ -1108,6 +1110,41 @@ const outboundDedup = new OutboundDedupCache()
|
|
|
1108
1110
|
const chatAvailableReactions = new Map<string, Set<string> | null>()
|
|
1109
1111
|
const chatProbesInFlight = new Set<string>()
|
|
1110
1112
|
const activeTurnStartedAt = new Map<string, number>()
|
|
1113
|
+
// PR3b parallel-turns: tracks turns claude has ACTUALLY been handed
|
|
1114
|
+
// (set after successful sendToAgent, cleared on turn_end), as opposed
|
|
1115
|
+
// to activeTurnStartedAt which is set eagerly on inbound receipt to
|
|
1116
|
+
// stamp the user-visible turn start time. Under fleet-shared and DM
|
|
1117
|
+
// topologies these are equivalent — every received inbound is delivered.
|
|
1118
|
+
// Under supergroup-owned (one agent owns the whole supergroup, multiple
|
|
1119
|
+
// topics share this gateway process), topic B's inbound that arrives
|
|
1120
|
+
// while topic A is processing gets buffered; without this split, keyB
|
|
1121
|
+
// stays in activeTurnStartedAt forever (no turn_end ever fires for a
|
|
1122
|
+
// turn claude never started), so the fleet-wide "claude is idle" gate
|
|
1123
|
+
// at purgeReactionTracking/releaseTurnBufferGate never re-opens — the
|
|
1124
|
+
// canonical supergroup-mode deadlock. Fleet gates read claudeBusyKeys;
|
|
1125
|
+
// per-key reads (status-query metric, wedge detection, etc.) keep
|
|
1126
|
+
// reading activeTurnStartedAt because they want the receipt timestamp.
|
|
1127
|
+
const claudeBusyKeys = new Set<string>()
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Helper: stamp a claudeBusyKeys entry for an inbound about to be
|
|
1131
|
+
* handed to claude. Pulls the thread id from the top-level field if
|
|
1132
|
+
* present, otherwise falls back to meta.message_thread_id (cron and
|
|
1133
|
+
* vault-synthetic inbounds put it there). chatKey canonicalises
|
|
1134
|
+
* null/undefined/0 to `_` so callers don't need to think about it.
|
|
1135
|
+
*/
|
|
1136
|
+
function markClaudeBusyForInbound(m: {
|
|
1137
|
+
chatId: string
|
|
1138
|
+
threadId?: number
|
|
1139
|
+
meta?: Record<string, string>
|
|
1140
|
+
}): void {
|
|
1141
|
+
let tid: number | null = m.threadId ?? null
|
|
1142
|
+
if (tid == null && m.meta?.message_thread_id != null) {
|
|
1143
|
+
const n = Number(m.meta.message_thread_id)
|
|
1144
|
+
if (Number.isFinite(n)) tid = n
|
|
1145
|
+
}
|
|
1146
|
+
claudeBusyKeys.add(chatKey(m.chatId, tid))
|
|
1147
|
+
}
|
|
1111
1148
|
const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
|
|
1112
1149
|
|
|
1113
1150
|
// ─── Proactive context compaction (session.max_context_tokens) ──────────
|
|
@@ -1351,13 +1388,29 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
1351
1388
|
activeStatusReactions.delete(key)
|
|
1352
1389
|
activeReactionMsgIds.delete(key)
|
|
1353
1390
|
activeTurnStartedAt.delete(key)
|
|
1391
|
+
// PR3b: clear the parallel-turns fleet-gate entry. Symmetric with
|
|
1392
|
+
// the markClaudeBusyForInbound on the delivery path. Safe no-op
|
|
1393
|
+
// when the key was never marked (synthetic purge from a sweep).
|
|
1394
|
+
claudeBusyKeys.delete(key)
|
|
1354
1395
|
// Human-feel UX: stop the turn-long `typing…` indicator started in
|
|
1355
1396
|
// the turn-start block. `purgeReactionTracking` is the canonical
|
|
1356
1397
|
// turn-end, so this is the single owner of the stop. (If an abnormal
|
|
1357
1398
|
// abort skips purge, the stray loop self-heals: the next turn on this
|
|
1358
1399
|
// chat calls `startTurnTypingLoop`, which stops the old interval
|
|
1359
1400
|
// first.)
|
|
1360
|
-
|
|
1401
|
+
// PR3 supergroup-mode: stop the per-(chat,thread) typing loop, not
|
|
1402
|
+
// the whole chat's. Prefer the ending-turn's session ids (the
|
|
1403
|
+
// canonical turn ownership); fall back to parsing the chatKey
|
|
1404
|
+
// for sibling-purge / restart-cleanup callers that don't have a
|
|
1405
|
+
// Turn handle.
|
|
1406
|
+
if (endingTurn != null) {
|
|
1407
|
+
stopTurnTypingLoop(endingTurn.sessionChatId, endingTurn.sessionThreadId ?? null)
|
|
1408
|
+
} else {
|
|
1409
|
+
const chatId = chatIdOfChatKey(key as _ChatKey)
|
|
1410
|
+
const threadPart = (key as string).slice(chatId.length + 1)
|
|
1411
|
+
const threadId = threadPart === '_' || threadPart === '' ? null : Number(threadPart)
|
|
1412
|
+
stopTurnTypingLoop(chatId, Number.isFinite(threadId) ? threadId : null)
|
|
1413
|
+
}
|
|
1361
1414
|
if (msgInfo) {
|
|
1362
1415
|
const agentDir = resolveAgentDirFromEnv()
|
|
1363
1416
|
if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
|
|
@@ -1377,7 +1430,13 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
1377
1430
|
// survives us getting killed by our own restart. Fire-and-forget;
|
|
1378
1431
|
// response to the client was already sent when the restart was
|
|
1379
1432
|
// scheduled, so nobody is waiting on this.
|
|
1380
|
-
|
|
1433
|
+
//
|
|
1434
|
+
// PR3b: gated on `claudeBusyKeys.size`, not `activeTurnStartedAt.size`,
|
|
1435
|
+
// so a buffered topic-B inbound (which had eagerly set its own
|
|
1436
|
+
// activeTurnStartedAt entry in the fresh-turn branch) doesn't pin this
|
|
1437
|
+
// gate forever while claude is genuinely idle. See the claudeBusyKeys
|
|
1438
|
+
// declaration for the supergroup deadlock this fixes.
|
|
1439
|
+
if (claudeBusyKeys.size === 0) {
|
|
1381
1440
|
// #1556: the deterministic delivery point. claude has just gone
|
|
1382
1441
|
// idle — flush any inbound held mid-turn so the channel
|
|
1383
1442
|
// notification lands at the idle prompt and submits as a fresh
|
|
@@ -1390,7 +1449,11 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
1390
1449
|
const fr = redeliverBufferedInbound(
|
|
1391
1450
|
pendingInboundBuffer,
|
|
1392
1451
|
selfAgentForFlush,
|
|
1393
|
-
(m) =>
|
|
1452
|
+
(m) => {
|
|
1453
|
+
const d = ipcServer.sendToAgent(selfAgentForFlush, m)
|
|
1454
|
+
if (d) markClaudeBusyForInbound(m)
|
|
1455
|
+
return d
|
|
1456
|
+
},
|
|
1394
1457
|
inboundSpool,
|
|
1395
1458
|
)
|
|
1396
1459
|
if (fr.redelivered > 0) {
|
|
@@ -1458,6 +1521,9 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
|
|
|
1458
1521
|
function releaseTurnBufferGate(key: string): void {
|
|
1459
1522
|
if (!activeTurnStartedAt.has(key)) return
|
|
1460
1523
|
activeTurnStartedAt.delete(key)
|
|
1524
|
+
// PR3b: keep claudeBusyKeys in sync — same lifecycle as the
|
|
1525
|
+
// activeTurnStartedAt entry it's mirroring here.
|
|
1526
|
+
claudeBusyKeys.delete(key)
|
|
1461
1527
|
// Shadow trace so the structural turn-end metric still records.
|
|
1462
1528
|
// outboundEmitted=true is correct here — we only reach this from
|
|
1463
1529
|
// executeReply AFTER an outbound landed.
|
|
@@ -1468,13 +1534,19 @@ function releaseTurnBufferGate(key: string): void {
|
|
|
1468
1534
|
// hits zero-active-turns, drain any held inbound. This is the
|
|
1469
1535
|
// load-bearing wedge fix: the gate that pinned msg 1874+ in
|
|
1470
1536
|
// test-harness's 13:02 UAT now opens after the reply.
|
|
1471
|
-
|
|
1537
|
+
//
|
|
1538
|
+
// PR3b: gated on claudeBusyKeys (see purgeReactionTracking comment).
|
|
1539
|
+
if (claudeBusyKeys.size === 0) {
|
|
1472
1540
|
const selfAgentForFlush = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
1473
1541
|
if (pendingInboundBuffer.depth(selfAgentForFlush) > 0) {
|
|
1474
1542
|
const fr = redeliverBufferedInbound(
|
|
1475
1543
|
pendingInboundBuffer,
|
|
1476
1544
|
selfAgentForFlush,
|
|
1477
|
-
(m) =>
|
|
1545
|
+
(m) => {
|
|
1546
|
+
const d = ipcServer.sendToAgent(selfAgentForFlush, m)
|
|
1547
|
+
if (d) markClaudeBusyForInbound(m)
|
|
1548
|
+
return d
|
|
1549
|
+
},
|
|
1478
1550
|
inboundSpool,
|
|
1479
1551
|
)
|
|
1480
1552
|
if (fr.redelivered > 0) {
|
|
@@ -1636,7 +1708,15 @@ async function postCompactCard(occ: number, cap: number): Promise<void> {
|
|
|
1636
1708
|
try {
|
|
1637
1709
|
const chatId = loadAccess().allowFrom[0];
|
|
1638
1710
|
if (!chatId) return;
|
|
1639
|
-
|
|
1711
|
+
// PR4b-compact: supergroup-owned agents route the compaction card
|
|
1712
|
+
// into the `alerts` alias topic (or default_topic_id fallback) so
|
|
1713
|
+
// operators see lifecycle/system events in a predictable lane
|
|
1714
|
+
// instead of conversation lanes. Fleet/DM agents fall through to
|
|
1715
|
+
// the existing chatThreadMap last-seen-thread fallback (no
|
|
1716
|
+
// observable change).
|
|
1717
|
+
const threadId =
|
|
1718
|
+
resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
|
|
1719
|
+
?? chatThreadMap.get(chatId);
|
|
1640
1720
|
const text =
|
|
1641
1721
|
`🗜️ <b>Context compaction</b>\n` +
|
|
1642
1722
|
`Working context hit ~${occ.toLocaleString()} tokens ` +
|
|
@@ -1875,6 +1955,13 @@ function escapeMarkdownV2(text: string): string {
|
|
|
1875
1955
|
}
|
|
1876
1956
|
|
|
1877
1957
|
// ─── Typing indicator ─────────────────────────────────────────────────────
|
|
1958
|
+
// All four state maps re-keyed from `chat_id` to `chatKey(chat, thread)`
|
|
1959
|
+
// in PR3 of the supergroup-mode rollout. In supergroup mode one agent
|
|
1960
|
+
// owns many topics in one chat; chatId-only keying meant topic A's
|
|
1961
|
+
// typing indicator died when topic B's tool call ended (last-stop-wins
|
|
1962
|
+
// on a shared key). Per-(chat,thread) keying preserves independent
|
|
1963
|
+
// typing loops across topics. Callers without thread context pass
|
|
1964
|
+
// `null` and behave exactly as before (chatKey collapses null→`_`).
|
|
1878
1965
|
const typingIntervals = new Map<string, ReturnType<typeof setInterval>>()
|
|
1879
1966
|
// Track pending backoff-retry timers so shutdown and stop can cancel them.
|
|
1880
1967
|
const typingRetryTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
@@ -1903,34 +1990,41 @@ const CHAT_ACTION_WHITELIST = new Set([
|
|
|
1903
1990
|
] as const)
|
|
1904
1991
|
type ChatAction = typeof CHAT_ACTION_WHITELIST extends Set<infer T> ? T : never
|
|
1905
1992
|
|
|
1906
|
-
function startTypingLoop(
|
|
1907
|
-
|
|
1993
|
+
function startTypingLoop(
|
|
1994
|
+
chat_id: string,
|
|
1995
|
+
thread_id: number | null = null,
|
|
1996
|
+
action: ChatAction = 'typing',
|
|
1997
|
+
): void {
|
|
1998
|
+
stopTypingLoop(chat_id, thread_id)
|
|
1999
|
+
const key = chatKey(chat_id, thread_id) as string
|
|
2000
|
+
const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
|
|
1908
2001
|
const send = () => {
|
|
1909
|
-
bot.api.sendChatAction(chat_id, action).then(
|
|
2002
|
+
bot.api.sendChatAction(chat_id, action, sendOpts).then(
|
|
1910
2003
|
() => { typingBackoffMs = 0 },
|
|
1911
2004
|
(err) => {
|
|
1912
2005
|
const msg = err instanceof Error ? err.message : String(err)
|
|
1913
2006
|
if (msg.includes('401') || msg.includes('Unauthorized')) {
|
|
1914
2007
|
typingBackoffMs = Math.min(Math.max(typingBackoffMs * 2 || 1000, 1000), TYPING_BACKOFF_MAX)
|
|
1915
|
-
stopTypingLoop(chat_id)
|
|
2008
|
+
stopTypingLoop(chat_id, thread_id)
|
|
1916
2009
|
const retry = setTimeout(() => {
|
|
1917
|
-
typingRetryTimers.delete(
|
|
1918
|
-
startTypingLoop(chat_id, action)
|
|
2010
|
+
typingRetryTimers.delete(key)
|
|
2011
|
+
startTypingLoop(chat_id, thread_id, action)
|
|
1919
2012
|
}, typingBackoffMs)
|
|
1920
|
-
typingRetryTimers.set(
|
|
2013
|
+
typingRetryTimers.set(key, retry)
|
|
1921
2014
|
}
|
|
1922
2015
|
},
|
|
1923
2016
|
)
|
|
1924
2017
|
}
|
|
1925
2018
|
send()
|
|
1926
|
-
typingIntervals.set(
|
|
2019
|
+
typingIntervals.set(key, setInterval(send, 4000))
|
|
1927
2020
|
}
|
|
1928
2021
|
|
|
1929
|
-
function stopTypingLoop(chat_id: string): void {
|
|
1930
|
-
const
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
2022
|
+
function stopTypingLoop(chat_id: string, thread_id: number | null = null): void {
|
|
2023
|
+
const key = chatKey(chat_id, thread_id) as string
|
|
2024
|
+
const iv = typingIntervals.get(key)
|
|
2025
|
+
if (iv) { clearInterval(iv); typingIntervals.delete(key) }
|
|
2026
|
+
const retry = typingRetryTimers.get(key)
|
|
2027
|
+
if (retry) { clearTimeout(retry); typingRetryTimers.delete(key) }
|
|
1934
2028
|
}
|
|
1935
2029
|
|
|
1936
2030
|
// Turn-level `typing…` indicator. Deliberately a SEPARATE interval map
|
|
@@ -1945,18 +2039,21 @@ function stopTypingLoop(chat_id: string): void {
|
|
|
1945
2039
|
// sendChatAction is cheap.
|
|
1946
2040
|
const turnTypingIntervals = new Map<string, ReturnType<typeof setInterval>>()
|
|
1947
2041
|
|
|
1948
|
-
function startTurnTypingLoop(chat_id: string): void {
|
|
1949
|
-
stopTurnTypingLoop(chat_id)
|
|
2042
|
+
function startTurnTypingLoop(chat_id: string, thread_id: number | null = null): void {
|
|
2043
|
+
stopTurnTypingLoop(chat_id, thread_id)
|
|
2044
|
+
const key = chatKey(chat_id, thread_id) as string
|
|
2045
|
+
const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
|
|
1950
2046
|
const send = () => {
|
|
1951
|
-
void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
|
|
2047
|
+
void bot.api.sendChatAction(chat_id, 'typing', sendOpts).catch(() => {})
|
|
1952
2048
|
}
|
|
1953
2049
|
send()
|
|
1954
|
-
turnTypingIntervals.set(
|
|
2050
|
+
turnTypingIntervals.set(key, setInterval(send, 4000))
|
|
1955
2051
|
}
|
|
1956
2052
|
|
|
1957
|
-
function stopTurnTypingLoop(chat_id: string): void {
|
|
1958
|
-
const
|
|
1959
|
-
|
|
2053
|
+
function stopTurnTypingLoop(chat_id: string, thread_id: number | null = null): void {
|
|
2054
|
+
const key = chatKey(chat_id, thread_id) as string
|
|
2055
|
+
const iv = turnTypingIntervals.get(key)
|
|
2056
|
+
if (iv) { clearInterval(iv); turnTypingIntervals.delete(key) }
|
|
1960
2057
|
}
|
|
1961
2058
|
|
|
1962
2059
|
const typingWrapper = createTypingWrapper({
|
|
@@ -2903,8 +3000,18 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
2903
3000
|
return
|
|
2904
3001
|
}
|
|
2905
3002
|
|
|
3003
|
+
// PR4b emitter sweep: supergroup-mode agents route operator-event
|
|
3004
|
+
// cards (boot crash, restart-watchdog, model-unavailable, etc.) into
|
|
3005
|
+
// the alerts/admin alias topic instead of chat-root. The
|
|
3006
|
+
// 'compact-watchdog' kind covers all system-initiated notifications
|
|
3007
|
+
// — alerts alias → default_topic_id fallback (see router contract).
|
|
3008
|
+
// Fleet-shared / DM agents see `undefined` → no `message_thread_id`
|
|
3009
|
+
// is added to the broadcast opts → behavior unchanged.
|
|
3010
|
+
const opEventTopic = resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
|
|
3011
|
+
|
|
2906
3012
|
process.stderr.write(
|
|
2907
|
-
`telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)
|
|
3013
|
+
`telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)` +
|
|
3014
|
+
(opEventTopic != null ? ` topic=${opEventTopic}` : '') + '\n',
|
|
2908
3015
|
)
|
|
2909
3016
|
for (const chat_id of access.allowFrom) {
|
|
2910
3017
|
// grammy's Other<...> opts type is generated and stricter than our
|
|
@@ -2912,8 +3019,12 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
|
|
|
2912
3019
|
const opts = {
|
|
2913
3020
|
parse_mode: 'HTML' as const,
|
|
2914
3021
|
...(renderedKeyboard ? { reply_markup: renderedKeyboard } : {}),
|
|
3022
|
+
...(opEventTopic != null ? { message_thread_id: opEventTopic } : {}),
|
|
2915
3023
|
}
|
|
2916
|
-
//
|
|
3024
|
+
// Comment-only context for the reader; the lint marker on the
|
|
3025
|
+
// very next line is what unlocks the raw bot.api call.
|
|
3026
|
+
// Opts now includes message_thread_id when supergroup mode is on.
|
|
3027
|
+
// allow-raw-bot-api: operator-event broadcast loop; topic-aware opts
|
|
2917
3028
|
void bot.api.sendMessage(chat_id, renderedText, opts as never).catch(e => {
|
|
2918
3029
|
process.stderr.write(
|
|
2919
3030
|
`telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}\n`,
|
|
@@ -3405,7 +3516,11 @@ silencePoke.startTimer({
|
|
|
3405
3516
|
const fbRedeliver = redeliverBufferedInbound(
|
|
3406
3517
|
pendingInboundBuffer,
|
|
3407
3518
|
fbSelfAgent,
|
|
3408
|
-
(m) =>
|
|
3519
|
+
(m) => {
|
|
3520
|
+
const d = ipcServer.sendToAgent(fbSelfAgent, m)
|
|
3521
|
+
if (d) markClaudeBusyForInbound(m)
|
|
3522
|
+
return d
|
|
3523
|
+
},
|
|
3409
3524
|
inboundSpool,
|
|
3410
3525
|
)
|
|
3411
3526
|
process.stderr.write(
|
|
@@ -3733,6 +3848,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3733
3848
|
activeStatusReactions,
|
|
3734
3849
|
activeReactionMsgIds,
|
|
3735
3850
|
activeTurnStartedAt,
|
|
3851
|
+
claudeBusyKeys,
|
|
3736
3852
|
activeDraftStreams,
|
|
3737
3853
|
activeDraftParseModes,
|
|
3738
3854
|
clearActiveReactions: () => {
|
|
@@ -3908,13 +4024,36 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3908
4024
|
.row()
|
|
3909
4025
|
.text(`🔁 Always allow ${alwaysRule.label}`, `perm:always:${requestId}`)
|
|
3910
4026
|
}
|
|
4027
|
+
// PR4b emitter sweep — supergroup-mode permission card routing.
|
|
4028
|
+
// Per CPO #3 the design is "turn-initiated requests follow the
|
|
4029
|
+
// conversation topic; background requests go to admin alias."
|
|
4030
|
+
// Permission requests come from the bridge mid-tool-use, so they
|
|
4031
|
+
// are always turn-initiated in practice — the currently active
|
|
4032
|
+
// turn's sessionThreadId is the originating topic. Fall back to
|
|
4033
|
+
// admin alias when no active turn (cron / background path).
|
|
4034
|
+
// Fleet-shared / DM agents see `undefined` → no
|
|
4035
|
+
// `message_thread_id` is added → behavior unchanged.
|
|
4036
|
+
// currentTurn is the singleton "claude is currently on this turn"
|
|
4037
|
+
// pointer — per Framing 1 / PR3b scope-discovery, claude
|
|
4038
|
+
// serializes so there's exactly one (or zero) active turn at any
|
|
4039
|
+
// moment. When set, the permission request is in-flight for that
|
|
4040
|
+
// turn and follows the originating topic.
|
|
4041
|
+
const activeTurn = currentTurn
|
|
4042
|
+
const permTopic = resolveAgentOutboundTopic({
|
|
4043
|
+
kind: 'permission',
|
|
4044
|
+
turnInitiated: activeTurn != null,
|
|
4045
|
+
originThreadId: activeTurn?.sessionThreadId,
|
|
4046
|
+
})
|
|
3911
4047
|
for (const chat_id of access.allowFrom) {
|
|
3912
4048
|
// parse_mode=HTML pairs with formatPermissionCardBody (#1790)
|
|
3913
4049
|
// so the <b>/<i> tags render as formatting.
|
|
3914
|
-
//
|
|
4050
|
+
// PR4b emitter sweep — opts now optionally carries
|
|
4051
|
+
// message_thread_id when supergroup mode is on.
|
|
4052
|
+
// allow-raw-bot-api: permission-request keyboard fan-out; topic-aware opts
|
|
3915
4053
|
void bot.api.sendMessage(chat_id, text, {
|
|
3916
4054
|
parse_mode: 'HTML',
|
|
3917
4055
|
reply_markup: keyboard,
|
|
4056
|
+
...(permTopic != null ? { message_thread_id: permTopic } : {}),
|
|
3918
4057
|
}).catch(e => {
|
|
3919
4058
|
process.stderr.write(`telegram gateway: permission_request send to ${chat_id} failed: ${e}\n`)
|
|
3920
4059
|
})
|
|
@@ -3928,8 +4067,11 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
3928
4067
|
onScheduleRestart(client: IpcClient, msg: ScheduleRestartMessage) {
|
|
3929
4068
|
const { agentName } = msg;
|
|
3930
4069
|
|
|
3931
|
-
// Check if any turn is currently in flight
|
|
3932
|
-
|
|
4070
|
+
// Check if any turn is currently in flight.
|
|
4071
|
+
// PR3b: gated on claudeBusyKeys (actually-handed-to-claude turns)
|
|
4072
|
+
// not activeTurnStartedAt (receipt-eager), so a buffered topic-B
|
|
4073
|
+
// inbound doesn't pin this as turnInFlight=true forever.
|
|
4074
|
+
const turnInFlight = claudeBusyKeys.size > 0;
|
|
3933
4075
|
|
|
3934
4076
|
if (!turnInFlight) {
|
|
3935
4077
|
// No active turn, restart immediately. Cycle both the agent and
|
|
@@ -4034,7 +4176,18 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4034
4176
|
// routing surface (see how /folders posts) — this picks the
|
|
4035
4177
|
// DM path which is the common case; group-routing follow-up
|
|
4036
4178
|
// can extend this.
|
|
4037
|
-
|
|
4179
|
+
// PR4b emitter sweep — supergroup-mode adds an explicit
|
|
4180
|
+
// topic. Drive approval cards follow the originating turn
|
|
4181
|
+
// (operator-initiated tool call), admin alias fallback.
|
|
4182
|
+
const activeTurn = currentTurn
|
|
4183
|
+
const driveTopic = resolveAgentOutboundTopic({
|
|
4184
|
+
kind: 'hostd-approval',
|
|
4185
|
+
originThreadId: activeTurn?.sessionThreadId,
|
|
4186
|
+
})
|
|
4187
|
+
return {
|
|
4188
|
+
chatId: operator,
|
|
4189
|
+
...(driveTopic != null ? { threadId: driveTopic } : {}),
|
|
4190
|
+
}
|
|
4038
4191
|
},
|
|
4039
4192
|
registerApproval: async (args) => {
|
|
4040
4193
|
const r = await kernelApprovalRequest({
|
|
@@ -4082,6 +4235,76 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4082
4235
|
})
|
|
4083
4236
|
},
|
|
4084
4237
|
|
|
4238
|
+
/**
|
|
4239
|
+
* RFC #1873 §8 — MS-365 write approval card. Mirrors
|
|
4240
|
+
* onRequestDriveApproval but with the weak-metadata v1 preview shape
|
|
4241
|
+
* (no diff-preview — just plain text card).
|
|
4242
|
+
*/
|
|
4243
|
+
async onRequestMs365Approval(client: IpcClient, msg) {
|
|
4244
|
+
await handleRequestMs365Approval(client, msg, {
|
|
4245
|
+
agentName: getMyAgentName(),
|
|
4246
|
+
loadAllowFrom: () => loadAccess().allowFrom,
|
|
4247
|
+
loadTargetChat: () => {
|
|
4248
|
+
const access = loadAccess()
|
|
4249
|
+
const operator = access.allowFrom[0]
|
|
4250
|
+
if (operator === undefined) return null
|
|
4251
|
+
// PR4b emitter sweep — MS365 write approval cards route into
|
|
4252
|
+
// the originating turn's topic in supergroup mode, admin
|
|
4253
|
+
// alias fallback for background cases. Same shape as hostd /
|
|
4254
|
+
// drive approvals below.
|
|
4255
|
+
const activeTurn = currentTurn
|
|
4256
|
+
const ms365Topic = resolveAgentOutboundTopic({
|
|
4257
|
+
kind: 'hostd-approval',
|
|
4258
|
+
originThreadId: activeTurn?.sessionThreadId,
|
|
4259
|
+
})
|
|
4260
|
+
return {
|
|
4261
|
+
chatId: operator,
|
|
4262
|
+
...(ms365Topic != null ? { threadId: ms365Topic } : {}),
|
|
4263
|
+
}
|
|
4264
|
+
},
|
|
4265
|
+
registerApproval: async (args) => {
|
|
4266
|
+
const r = await kernelApprovalRequest({
|
|
4267
|
+
agent_unit: args.agent_unit,
|
|
4268
|
+
scope: args.scope,
|
|
4269
|
+
action: args.action,
|
|
4270
|
+
approver_set: args.approver_set,
|
|
4271
|
+
why: args.why,
|
|
4272
|
+
ttl_ms: args.ttl_ms,
|
|
4273
|
+
})
|
|
4274
|
+
if (r === null || r.state === 'rate_limited') return null
|
|
4275
|
+
return {
|
|
4276
|
+
request_id: r.request_id,
|
|
4277
|
+
expires_at_ms: r.expires_at,
|
|
4278
|
+
}
|
|
4279
|
+
},
|
|
4280
|
+
postCard: async (args) => {
|
|
4281
|
+
try {
|
|
4282
|
+
const sent = await robustApiCall(
|
|
4283
|
+
() =>
|
|
4284
|
+
bot.api.sendMessage(args.chatId, args.text, {
|
|
4285
|
+
...(args.threadId !== undefined
|
|
4286
|
+
? { message_thread_id: args.threadId }
|
|
4287
|
+
: {}),
|
|
4288
|
+
reply_markup: args.replyMarkup as never,
|
|
4289
|
+
}),
|
|
4290
|
+
{
|
|
4291
|
+
chat_id: String(args.chatId),
|
|
4292
|
+
verb: 'ms365-approval-card',
|
|
4293
|
+
...(args.threadId !== undefined ? { threadId: args.threadId } : {}),
|
|
4294
|
+
},
|
|
4295
|
+
)
|
|
4296
|
+
return { messageId: (sent as { message_id: number }).message_id }
|
|
4297
|
+
} catch (err) {
|
|
4298
|
+
process.stderr.write(
|
|
4299
|
+
`telegram gateway: ms365-approval postCard failed: ${(err as Error).message}\n`,
|
|
4300
|
+
)
|
|
4301
|
+
return null
|
|
4302
|
+
}
|
|
4303
|
+
},
|
|
4304
|
+
log: (m) => process.stderr.write(`telegram gateway: ms365-approval — ${m}\n`),
|
|
4305
|
+
})
|
|
4306
|
+
},
|
|
4307
|
+
|
|
4085
4308
|
/**
|
|
4086
4309
|
* #1623 — hostd-initiated config-edit approval card. hostd posts a
|
|
4087
4310
|
* `request_config_approval` message; we render the card via
|
|
@@ -4102,7 +4325,21 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4102
4325
|
const access = loadAccess()
|
|
4103
4326
|
const operator = access.allowFrom[0]
|
|
4104
4327
|
if (operator === undefined) return null
|
|
4105
|
-
|
|
4328
|
+
// PR4b emitter sweep — hostd config-approval cards are
|
|
4329
|
+
// operator-initiated (someone typed /update apply or tapped
|
|
4330
|
+
// a button). Follow the originating turn when there is one;
|
|
4331
|
+
// admin alias fallback in supergroup mode otherwise. Helper
|
|
4332
|
+
// returns undefined for non-supergroup agents → behavior
|
|
4333
|
+
// unchanged.
|
|
4334
|
+
const activeTurn = currentTurn
|
|
4335
|
+
const cfgTopic = resolveAgentOutboundTopic({
|
|
4336
|
+
kind: 'hostd-approval',
|
|
4337
|
+
originThreadId: activeTurn?.sessionThreadId,
|
|
4338
|
+
})
|
|
4339
|
+
return {
|
|
4340
|
+
chatId: operator,
|
|
4341
|
+
...(cfgTopic != null ? { threadId: cfgTopic } : {}),
|
|
4342
|
+
}
|
|
4106
4343
|
},
|
|
4107
4344
|
buildKeyboard: (requestId) =>
|
|
4108
4345
|
new InlineKeyboard()
|
|
@@ -4204,6 +4441,7 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4204
4441
|
? msg.inbound.meta.source
|
|
4205
4442
|
: 'unknown'
|
|
4206
4443
|
const delivered = ipcServer.sendToAgent(msg.agentName, msg.inbound)
|
|
4444
|
+
if (delivered) markClaudeBusyForInbound(msg.inbound)
|
|
4207
4445
|
process.stderr.write(
|
|
4208
4446
|
`telegram gateway: inject_inbound agent=${msg.agentName} source=${source} prompt_key=${promptKey} delivered=${delivered}\n`,
|
|
4209
4447
|
)
|
|
@@ -4252,11 +4490,16 @@ if (!STATIC) {
|
|
|
4252
4490
|
() => {
|
|
4253
4491
|
// #1556: never drain mid-turn — that re-creates the composer
|
|
4254
4492
|
// wedge this buffer exists to prevent.
|
|
4255
|
-
|
|
4493
|
+
// PR3b: gated on claudeBusyKeys (see purgeReactionTracking).
|
|
4494
|
+
if (claudeBusyKeys.size > 0) return false
|
|
4256
4495
|
const c = ipcServer.getClient(selfAgent)
|
|
4257
4496
|
return c != null && c.isAlive()
|
|
4258
4497
|
},
|
|
4259
|
-
(m) =>
|
|
4498
|
+
(m) => {
|
|
4499
|
+
const d = ipcServer.sendToAgent(selfAgent, m)
|
|
4500
|
+
if (d) markClaudeBusyForInbound(m)
|
|
4501
|
+
return d
|
|
4502
|
+
},
|
|
4260
4503
|
inboundSpool,
|
|
4261
4504
|
)
|
|
4262
4505
|
if (r != null && r.redelivered > 0) {
|
|
@@ -4672,7 +4915,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4672
4915
|
previewMessageId = null
|
|
4673
4916
|
}
|
|
4674
4917
|
|
|
4675
|
-
startTypingLoop(chat_id)
|
|
4918
|
+
startTypingLoop(chat_id, threadId ?? null)
|
|
4676
4919
|
|
|
4677
4920
|
// #1677 silent-reply auto-edit. Consecutive silent replies within
|
|
4678
4921
|
// a turn edit a single anchor message instead of stacking new
|
|
@@ -4804,7 +5047,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4804
5047
|
if (silentAnchorEditDone) {
|
|
4805
5048
|
// Skip the chunk loop entirely — the anchor edit IS the send.
|
|
4806
5049
|
// Match the normal exit path: stop typing, then return.
|
|
4807
|
-
stopTypingLoop(chat_id)
|
|
5050
|
+
stopTypingLoop(chat_id, threadId ?? null)
|
|
4808
5051
|
return {
|
|
4809
5052
|
content: [
|
|
4810
5053
|
{
|
|
@@ -4921,7 +5164,7 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
4921
5164
|
const msg = err instanceof Error ? err.message : String(err)
|
|
4922
5165
|
throw new Error(`reply failed after ${sentIds.length} of ${chunks.length} chunk(s) sent: ${msg}`)
|
|
4923
5166
|
} finally {
|
|
4924
|
-
stopTypingLoop(chat_id)
|
|
5167
|
+
stopTypingLoop(chat_id, threadId ?? null)
|
|
4925
5168
|
}
|
|
4926
5169
|
|
|
4927
5170
|
// #710: remember per-button agent meta (ack_text / single_use) keyed
|
|
@@ -6281,8 +6524,12 @@ async function executeSendTyping(args: Record<string, unknown>): Promise<unknown
|
|
|
6281
6524
|
}
|
|
6282
6525
|
action = rawAction as ChatAction
|
|
6283
6526
|
}
|
|
6284
|
-
|
|
6285
|
-
|
|
6527
|
+
// PR3 supergroup-mode: resolve thread from args or fall back to the
|
|
6528
|
+
// last-seen thread for this chat so the indicator lands in the topic
|
|
6529
|
+
// the agent is working in (rather than the chat root).
|
|
6530
|
+
const stThreadId = resolveThreadId(stChatId, args.message_thread_id as string | number | undefined) ?? null
|
|
6531
|
+
startTypingLoop(stChatId, stThreadId, action)
|
|
6532
|
+
setTimeout(() => stopTypingLoop(stChatId, stThreadId), 30000)
|
|
6286
6533
|
for (const [key, ctrl] of activeStatusReactions.entries()) {
|
|
6287
6534
|
if (key.startsWith(`${stChatId}:`)) ctrl.setTool()
|
|
6288
6535
|
}
|
|
@@ -6632,7 +6879,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6632
6879
|
if (isTelegramSurfaceTool(name)) return
|
|
6633
6880
|
ctrl.setTool(name)
|
|
6634
6881
|
if (ev.toolUseId) {
|
|
6635
|
-
typingWrapper.onToolUse(ev.toolUseId, turn.sessionChatId, name)
|
|
6882
|
+
typingWrapper.onToolUse(ev.toolUseId, turn.sessionChatId, name, turn.sessionThreadId ?? null)
|
|
6636
6883
|
}
|
|
6637
6884
|
return
|
|
6638
6885
|
}
|
|
@@ -6875,7 +7122,7 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
6875
7122
|
const turn = currentTurn
|
|
6876
7123
|
if (turn == null) return
|
|
6877
7124
|
if (!ev.toolUseId) return
|
|
6878
|
-
typingWrapper.onToolUse(ev.toolUseId, turn.sessionChatId, ev.toolName)
|
|
7125
|
+
typingWrapper.onToolUse(ev.toolUseId, turn.sessionChatId, ev.toolName, turn.sessionThreadId ?? null)
|
|
6879
7126
|
return
|
|
6880
7127
|
}
|
|
6881
7128
|
case 'sub_agent_tool_result': {
|
|
@@ -7870,7 +8117,11 @@ async function handleInboundCoalesced(
|
|
|
7870
8117
|
// defensive against future routers that might call this without one).
|
|
7871
8118
|
maybeEarlyAckReaction(ctx, from)
|
|
7872
8119
|
|
|
7873
|
-
const key = inboundCoalesceKey(
|
|
8120
|
+
const key = inboundCoalesceKey(
|
|
8121
|
+
String(ctx.chat!.id),
|
|
8122
|
+
ctx.message?.message_thread_id,
|
|
8123
|
+
String(from.id),
|
|
8124
|
+
)
|
|
7874
8125
|
const result = inboundCoalescer.enqueue(key, { text, ctx, downloadImage, attachment })
|
|
7875
8126
|
if (result.bypass) return handleInbound(ctx, text, undefined, undefined)
|
|
7876
8127
|
}
|
|
@@ -7988,7 +8239,16 @@ async function handleInbound(
|
|
|
7988
8239
|
// an ack on the buffered path). The snapshot is the minimal precise
|
|
7989
8240
|
// fix. Phase 2b's state-machine extraction will revisit this
|
|
7990
8241
|
// structurally.
|
|
7991
|
-
|
|
8242
|
+
// PR3b: gated on claudeBusyKeys (turns claude has been handed) not
|
|
8243
|
+
// activeTurnStartedAt (eager set on receipt). In supergroup mode,
|
|
8244
|
+
// topic A active + topic B inbound arriving: pre-fix, B saw
|
|
8245
|
+
// turnInFlightAtReceipt=true because A's key was in
|
|
8246
|
+
// activeTurnStartedAt, AND B's fresh-turn branch then eagerly set
|
|
8247
|
+
// its OWN key — wedging the gate forever (claude is idle on B but
|
|
8248
|
+
// no turn_end ever fires). With claudeBusyKeys, B sees true (A is
|
|
8249
|
+
// busy) → B is buffered correctly, AND the gate cleanly reopens
|
|
8250
|
+
// when A's turn_end deletes keyA → flush triggers → B delivered.
|
|
8251
|
+
const turnInFlightAtReceipt = claudeBusyKeys.size > 0
|
|
7992
8252
|
|
|
7993
8253
|
const access = result.access
|
|
7994
8254
|
const from = ctx.from!
|
|
@@ -8167,11 +8427,17 @@ async function handleInbound(
|
|
|
8167
8427
|
// hygiene). The add-flow intercept comes first because /auth add
|
|
8168
8428
|
// creates fresh credentials at the broker layer, vs /reauth which
|
|
8169
8429
|
// mutates an existing agent's slot — different success paths.
|
|
8170
|
-
|
|
8430
|
+
//
|
|
8431
|
+
// PR3 supergroup-mode: keyed by chatKey(chat, thread) so an OAuth
|
|
8432
|
+
// code pasted into topic A isn't intercepted when topic B has a
|
|
8433
|
+
// separate /auth add flow pending (security: prevents cross-topic
|
|
8434
|
+
// credential mis-attribution).
|
|
8435
|
+
const interceptKey = chatKey(chat_id, messageThreadId) as string
|
|
8436
|
+
const pendingAdd = pendingAuthAddFlows.get(interceptKey)
|
|
8171
8437
|
if (pendingAdd && looksLikeAuthCode(text)) {
|
|
8172
8438
|
const elapsed = Date.now() - pendingAdd.startedAt
|
|
8173
8439
|
if (elapsed < REAUTH_INTERCEPT_TTL_MS) {
|
|
8174
|
-
pendingAuthAddFlows.delete(
|
|
8440
|
+
pendingAuthAddFlows.delete(interceptKey)
|
|
8175
8441
|
try {
|
|
8176
8442
|
const credentials = await submitAccountAuthCode(pendingAdd, text.trim())
|
|
8177
8443
|
try {
|
|
@@ -8212,15 +8478,15 @@ async function handleInbound(
|
|
|
8212
8478
|
// Stale — drop the pending entry but let the message fall through
|
|
8213
8479
|
// to other intercepts (defensively wipe scratch).
|
|
8214
8480
|
cancelAccountAuthSession(pendingAdd)
|
|
8215
|
-
pendingAuthAddFlows.delete(
|
|
8481
|
+
pendingAuthAddFlows.delete(interceptKey)
|
|
8216
8482
|
}
|
|
8217
8483
|
|
|
8218
8484
|
// Auth-code intercept
|
|
8219
|
-
const pendingReauth = pendingReauthFlows.get(
|
|
8485
|
+
const pendingReauth = pendingReauthFlows.get(interceptKey)
|
|
8220
8486
|
if (pendingReauth && looksLikeAuthCode(text)) {
|
|
8221
8487
|
const elapsed = Date.now() - pendingReauth.startedAt
|
|
8222
8488
|
if (elapsed < REAUTH_INTERCEPT_TTL_MS) {
|
|
8223
|
-
pendingReauthFlows.delete(
|
|
8489
|
+
pendingReauthFlows.delete(interceptKey)
|
|
8224
8490
|
const { result, errorText } = execAuthCode(pendingReauth.agent, text.trim())
|
|
8225
8491
|
if (errorText) {
|
|
8226
8492
|
await switchroomReply(ctx, `<b>auth code failed:</b>\n${preBlock(formatSwitchroomOutput(errorText))}`, { html: true })
|
|
@@ -8242,7 +8508,7 @@ async function handleInbound(
|
|
|
8242
8508
|
redactAuthCodeMessage(bot.api as never, chat_id, msgId ?? null, line => process.stderr.write(line))
|
|
8243
8509
|
return
|
|
8244
8510
|
}
|
|
8245
|
-
pendingReauthFlows.delete(
|
|
8511
|
+
pendingReauthFlows.delete(interceptKey)
|
|
8246
8512
|
}
|
|
8247
8513
|
|
|
8248
8514
|
// Vault intercept
|
|
@@ -8740,7 +9006,10 @@ async function handleInbound(
|
|
|
8740
9006
|
// turn-end (`purgeReactionTracking → stopTurnTypingLoop`).
|
|
8741
9007
|
// Deterministic, framework-owned, no prose — the mechanical
|
|
8742
9008
|
// ambient layer of the pacing contract.
|
|
8743
|
-
|
|
9009
|
+
// PR3 supergroup-mode: pass thread so the indicator lands in
|
|
9010
|
+
// this turn's topic (otherwise topic A's turn-end would kill
|
|
9011
|
+
// topic B's typing indicator on shared chat_id keying).
|
|
9012
|
+
startTurnTypingLoop(chat_id, messageThreadId ?? null)
|
|
8744
9013
|
// #1122 KPI: emit turn_started so dashboards can compute funnel
|
|
8745
9014
|
// start counts + correlate to turn_ended for duration / TTFO.
|
|
8746
9015
|
emitRuntimeMetric({
|
|
@@ -8951,6 +9220,7 @@ async function handleInbound(
|
|
|
8951
9220
|
}
|
|
8952
9221
|
|
|
8953
9222
|
const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
|
|
9223
|
+
if (delivered) markClaudeBusyForInbound(inboundMsg)
|
|
8954
9224
|
if (!delivered) {
|
|
8955
9225
|
pendingInboundBuffer.push(selfAgent, inboundMsg)
|
|
8956
9226
|
const threadOpts = messageThreadId != null ? { message_thread_id: messageThreadId } : {}
|
|
@@ -9019,10 +9289,26 @@ type SwitchroomReplyMarkup =
|
|
|
9019
9289
|
async function switchroomReply(
|
|
9020
9290
|
ctx: Context,
|
|
9021
9291
|
text: string,
|
|
9022
|
-
options: {
|
|
9292
|
+
options: {
|
|
9293
|
+
html?: boolean
|
|
9294
|
+
reply_markup?: SwitchroomReplyMarkup
|
|
9295
|
+
// PR5 — supergroup-mode slash-command smart split (CPO #4).
|
|
9296
|
+
// 'query' (default semantics) follows the originating topic;
|
|
9297
|
+
// 'mutation' and 'heavy' route to admin alias in supergroup mode;
|
|
9298
|
+
// omitted/undefined preserves legacy in-place reply behavior.
|
|
9299
|
+
classification?: 'query' | 'mutation' | 'heavy'
|
|
9300
|
+
} = {},
|
|
9023
9301
|
): Promise<void> {
|
|
9024
9302
|
const chatId = String(ctx.chat!.id)
|
|
9025
|
-
const
|
|
9303
|
+
const baseThreadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
|
|
9304
|
+
// PR5 — when caller flagged the reply as mutation/heavy AND we're
|
|
9305
|
+
// in supergroup mode, override the in-place thread with the
|
|
9306
|
+
// admin-alias topic. For 'query' (or non-supergroup), routedOpts
|
|
9307
|
+
// is `{}` and baseThreadId wins → behavior unchanged.
|
|
9308
|
+
const routedOpts = options.classification
|
|
9309
|
+
? slashCommandReplyOpts(ctx, options.classification)
|
|
9310
|
+
: {}
|
|
9311
|
+
const threadId = routedOpts.message_thread_id ?? baseThreadId
|
|
9026
9312
|
await ctx.reply(text, {
|
|
9027
9313
|
...(threadId != null ? { message_thread_id: threadId } : {}),
|
|
9028
9314
|
...(options.html ? { parse_mode: 'HTML' as const, link_preview_options: { is_disabled: true } } : {}),
|
|
@@ -9194,7 +9480,10 @@ function resolveBootChatId(
|
|
|
9194
9480
|
marker: { chat_id: string; thread_id: number | null; ack_message_id: number | null; ts: number } | null,
|
|
9195
9481
|
ageMs?: number,
|
|
9196
9482
|
): { chatId: string; threadId: number | undefined; ackMsgId: number | undefined } | null {
|
|
9197
|
-
// 1. Restart marker
|
|
9483
|
+
// 1. Restart marker — operator-initiated; honor where they typed
|
|
9484
|
+
// /restart. The marker carries the exact chat+thread context; no
|
|
9485
|
+
// routing override because the user expects to see the boot card
|
|
9486
|
+
// in the same lane where they invoked the restart.
|
|
9198
9487
|
if (marker != null && (ageMs == null || ageMs < 5 * 60_000)) {
|
|
9199
9488
|
return {
|
|
9200
9489
|
chatId: marker.chat_id,
|
|
@@ -9202,9 +9491,19 @@ function resolveBootChatId(
|
|
|
9202
9491
|
ackMsgId: marker.ack_message_id ?? undefined,
|
|
9203
9492
|
}
|
|
9204
9493
|
}
|
|
9494
|
+
|
|
9495
|
+
// For non-marker paths (spontaneous boot, crash recovery, env var,
|
|
9496
|
+
// history fallback): supergroup-mode agents route the boot card to
|
|
9497
|
+
// the `alerts` alias topic (or default_topic_id fallback) so the
|
|
9498
|
+
// operator sees lifecycle events in a predictable lane instead of
|
|
9499
|
+
// chat-root. For fleet-mode / DM agents the helper returns undefined
|
|
9500
|
+
// → behavior unchanged (lands at chat-root as today). PR4b of
|
|
9501
|
+
// supergroup-mode rollout (docs/rfcs/supergroup-mode.md).
|
|
9502
|
+
const supergroupBootTopic = resolveAgentOutboundTopic({ kind: 'boot' })
|
|
9503
|
+
|
|
9205
9504
|
// 2. Env var
|
|
9206
9505
|
const envChat = process.env.SUBAGENT_OWNER_CHAT_ID
|
|
9207
|
-
if (envChat) return { chatId: envChat, threadId:
|
|
9506
|
+
if (envChat) return { chatId: envChat, threadId: supergroupBootTopic, ackMsgId: undefined }
|
|
9208
9507
|
// 3. Most-recent inbound from history
|
|
9209
9508
|
if (HISTORY_ENABLED) {
|
|
9210
9509
|
try {
|
|
@@ -9212,7 +9511,7 @@ function resolveBootChatId(
|
|
|
9212
9511
|
const ownerChatId = access.allowFrom[0]
|
|
9213
9512
|
if (ownerChatId) {
|
|
9214
9513
|
const recent = queryHistory({ chat_id: ownerChatId, limit: 1 })
|
|
9215
|
-
if (recent.length > 0) return { chatId: ownerChatId, threadId:
|
|
9514
|
+
if (recent.length > 0) return { chatId: ownerChatId, threadId: supergroupBootTopic, ackMsgId: undefined }
|
|
9216
9515
|
}
|
|
9217
9516
|
} catch {}
|
|
9218
9517
|
}
|
|
@@ -9220,6 +9519,40 @@ function resolveBootChatId(
|
|
|
9220
9519
|
return null
|
|
9221
9520
|
}
|
|
9222
9521
|
|
|
9522
|
+
/**
|
|
9523
|
+
* Resolve the supergroup-mode topic for an outbound event, or
|
|
9524
|
+
* undefined when the agent isn't in supergroup-owned mode. Best-effort
|
|
9525
|
+
* — any config-read failure returns undefined and the caller falls
|
|
9526
|
+
* through to today's behavior. Generic over every OutboundEvent
|
|
9527
|
+
* variant so the same helper backs boot card, compact card, vault,
|
|
9528
|
+
* permission, hostd, and watchdog emitters.
|
|
9529
|
+
*
|
|
9530
|
+
* Called sparingly (boot/reconnect, compaction edges, approval-card
|
|
9531
|
+
* dispatch) — not per turn — so the cost of a fresh config-read per
|
|
9532
|
+
* call is well within budget.
|
|
9533
|
+
*/
|
|
9534
|
+
function resolveAgentOutboundTopic(
|
|
9535
|
+
event: Parameters<typeof resolveOutboundTopicHelper>[1],
|
|
9536
|
+
): number | undefined {
|
|
9537
|
+
const agentName = process.env.SWITCHROOM_AGENT_NAME
|
|
9538
|
+
if (!agentName) return undefined
|
|
9539
|
+
try {
|
|
9540
|
+
const cfg = loadSwitchroomConfig()
|
|
9541
|
+
const rawAgent = cfg.agents?.[agentName]
|
|
9542
|
+
if (!rawAgent) return undefined
|
|
9543
|
+
const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
|
|
9544
|
+
const tg = resolved.channels?.telegram
|
|
9545
|
+
if (!tg) return undefined
|
|
9546
|
+
// The router treats the absence of default_topic_id as
|
|
9547
|
+
// "fleet-mode" and returns undefined for ops-lane events (the
|
|
9548
|
+
// caller's existing fallback). Only supergroup-owned agents
|
|
9549
|
+
// (with default_topic_id set) get a routed value.
|
|
9550
|
+
return resolveOutboundTopicHelper(tg as _OutboundRouterConfig, event)
|
|
9551
|
+
} catch {
|
|
9552
|
+
return undefined
|
|
9553
|
+
}
|
|
9554
|
+
}
|
|
9555
|
+
|
|
9223
9556
|
/**
|
|
9224
9557
|
* Stamp a user-facing restart reason into the clean-shutdown marker
|
|
9225
9558
|
* (same file the SIGTERM handler writes to and the next session greeting
|
|
@@ -9285,6 +9618,53 @@ function resolveSystemdRunPath(): string | null {
|
|
|
9285
9618
|
return _systemdRunPath
|
|
9286
9619
|
}
|
|
9287
9620
|
|
|
9621
|
+
/**
|
|
9622
|
+
* PR5 — supergroup-mode slash-command smart split (CPO #4 design).
|
|
9623
|
+
*
|
|
9624
|
+
* Classifies a slash-command response by event class and returns the
|
|
9625
|
+
* `message_thread_id` an outbound reply should target in supergroup
|
|
9626
|
+
* mode, or `undefined` when the agent isn't in supergroup-owned mode
|
|
9627
|
+
* (caller falls through to grammY's default `ctx.reply` which
|
|
9628
|
+
* routes to the originating topic).
|
|
9629
|
+
*
|
|
9630
|
+
* Three classes:
|
|
9631
|
+
* - `query` (`/help`, `/status`, etc.) — follows the originating
|
|
9632
|
+
* topic, identical to default `ctx.reply` behaviour.
|
|
9633
|
+
* - `mutation` (`/restart`, `/update apply`) — admin alias.
|
|
9634
|
+
* - `heavy` (`/logs`, `/audit`, `/upgradestatus`, `/memory <q>`)
|
|
9635
|
+
* — admin alias (operational separation per CPO #4).
|
|
9636
|
+
*
|
|
9637
|
+
* Callers spread the returned topic onto their send opts:
|
|
9638
|
+
*
|
|
9639
|
+
* await ctx.reply(text, {
|
|
9640
|
+
* ...opts,
|
|
9641
|
+
* ...slashCommandReplyOpts(ctx, 'heavy'),
|
|
9642
|
+
* })
|
|
9643
|
+
*
|
|
9644
|
+
* Returns `{}` when no override is needed (non-supergroup, or
|
|
9645
|
+
* helper resolved to the originating topic). Returns
|
|
9646
|
+
* `{ message_thread_id: N }` otherwise.
|
|
9647
|
+
*/
|
|
9648
|
+
function slashCommandReplyOpts(
|
|
9649
|
+
ctx: Context,
|
|
9650
|
+
classification: 'query' | 'mutation' | 'heavy',
|
|
9651
|
+
): { message_thread_id?: number } {
|
|
9652
|
+
const originThreadId = ctx.message?.message_thread_id
|
|
9653
|
+
const event =
|
|
9654
|
+
classification === 'query'
|
|
9655
|
+
? ({ kind: 'command-query', originThreadId } as const)
|
|
9656
|
+
: classification === 'mutation'
|
|
9657
|
+
? ({ kind: 'command-mutation' } as const)
|
|
9658
|
+
: ({ kind: 'command-heavy' } as const)
|
|
9659
|
+
const target = resolveAgentOutboundTopic(event)
|
|
9660
|
+
if (target == null) return {}
|
|
9661
|
+
// Avoid the spurious explicit thread_id when it would equal the
|
|
9662
|
+
// implicit-reply destination — keeps the wire identical to default
|
|
9663
|
+
// ctx.reply for the query class in supergroup mode.
|
|
9664
|
+
if (target === originThreadId) return {}
|
|
9665
|
+
return { message_thread_id: target }
|
|
9666
|
+
}
|
|
9667
|
+
|
|
9288
9668
|
/**
|
|
9289
9669
|
* Detect whether `docker` is callable from this process — required by
|
|
9290
9670
|
* `switchroom update`'s pull-images and recreate-containers steps.
|
|
@@ -9736,18 +10116,27 @@ async function dispatchShortVerbViaHostd(
|
|
|
9736
10116
|
)
|
|
9737
10117
|
}
|
|
9738
10118
|
|
|
9739
|
-
async function runSwitchroomCommand(
|
|
10119
|
+
async function runSwitchroomCommand(
|
|
10120
|
+
ctx: Context,
|
|
10121
|
+
args: string[],
|
|
10122
|
+
label: string,
|
|
10123
|
+
// PR5 — supergroup-mode classification threaded through to
|
|
10124
|
+
// switchroomReply. Default 'query' so existing callers (most
|
|
10125
|
+
// commands) preserve in-place reply behavior. /logs /audit
|
|
10126
|
+
// /upgradestatus /memory pass 'heavy' to route to admin alias.
|
|
10127
|
+
classification: 'query' | 'mutation' | 'heavy' = 'query',
|
|
10128
|
+
): Promise<void> {
|
|
9740
10129
|
try {
|
|
9741
10130
|
const output = stripAnsi(switchroomExec(args))
|
|
9742
10131
|
const formatted = formatSwitchroomOutput(output)
|
|
9743
|
-
if (formatted) { await switchroomReply(ctx, preBlock(formatted), { html: true }) }
|
|
9744
|
-
else { await switchroomReply(ctx, `${label}: done (no output)
|
|
10132
|
+
if (formatted) { await switchroomReply(ctx, preBlock(formatted), { html: true, classification }) }
|
|
10133
|
+
else { await switchroomReply(ctx, `${label}: done (no output)`, { classification }) }
|
|
9745
10134
|
} catch (err: unknown) {
|
|
9746
10135
|
const error = err as { status?: number; stderr?: string; message?: string }
|
|
9747
|
-
if (error.message?.includes('ENOENT')) { await switchroomReply(ctx, 'switchroom CLI not found.', { html: true }); return }
|
|
9748
|
-
if (error.message?.includes('ETIMEDOUT') || error.message?.includes('timed out')) { await switchroomReply(ctx, `${label}: timed out
|
|
10136
|
+
if (error.message?.includes('ENOENT')) { await switchroomReply(ctx, 'switchroom CLI not found.', { html: true, classification }); return }
|
|
10137
|
+
if (error.message?.includes('ETIMEDOUT') || error.message?.includes('timed out')) { await switchroomReply(ctx, `${label}: timed out`, { classification }); return }
|
|
9749
10138
|
const detail = stripAnsi(error.stderr?.trim() || error.message || 'unknown error')
|
|
9750
|
-
await switchroomReply(ctx, `<b>${escapeHtmlForTg(label)} failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true })
|
|
10139
|
+
await switchroomReply(ctx, `<b>${escapeHtmlForTg(label)} failed:</b>\n${preBlock(formatSwitchroomOutput(detail))}`, { html: true, classification })
|
|
9751
10140
|
}
|
|
9752
10141
|
}
|
|
9753
10142
|
|
|
@@ -10712,7 +11101,9 @@ bot.command('update', async ctx => {
|
|
|
10712
11101
|
// /upgrade-status. The /upgrade alias just below redirects.)
|
|
10713
11102
|
bot.command('upgradestatus', async ctx => {
|
|
10714
11103
|
if (!isAuthorizedSender(ctx)) return
|
|
10715
|
-
|
|
11104
|
+
// PR5 — heavy-output: route to admin alias in supergroup mode
|
|
11105
|
+
// (CPO #4). Fleet-shared / DM agents fall through to in-place reply.
|
|
11106
|
+
await runSwitchroomCommand(ctx, ['update', '--status'], 'update --status', 'heavy')
|
|
10716
11107
|
})
|
|
10717
11108
|
// Alias with hyphen — Grammy doesn't allow hyphens in command names
|
|
10718
11109
|
// (Telegram's slash-command grammar excludes them) but operators are
|
|
@@ -10800,7 +11191,8 @@ bot.command('audit', async ctx => {
|
|
|
10800
11191
|
)
|
|
10801
11192
|
return
|
|
10802
11193
|
}
|
|
10803
|
-
|
|
11194
|
+
// PR5 — heavy-output → admin alias in supergroup mode (CPO #4).
|
|
11195
|
+
await runSwitchroomCommand(ctx, argv, `hostd audit${argv.length > 2 ? ' …' : ''}`, 'heavy')
|
|
10804
11196
|
})
|
|
10805
11197
|
|
|
10806
11198
|
// ─── /approve, /deny, /pending ────────────────────────────────────────────
|
|
@@ -11242,19 +11634,24 @@ bot.command("auth", async ctx => {
|
|
|
11242
11634
|
)
|
|
11243
11635
|
return
|
|
11244
11636
|
}
|
|
11637
|
+
// PR3 supergroup-mode: key auth-add flows by (chat, thread) so
|
|
11638
|
+
// separate flows in two topics of one supergroup can't collide.
|
|
11639
|
+
// In DM chats message_thread_id is undefined → key collapses to
|
|
11640
|
+
// `chatId:_`, identical to today's behavior.
|
|
11641
|
+
const authAddKey = chatKey(chatId, ctx.message?.message_thread_id ?? null) as string
|
|
11245
11642
|
if (parsed.kind === 'cancel') {
|
|
11246
|
-
const existing = pendingAuthAddFlows.get(
|
|
11643
|
+
const existing = pendingAuthAddFlows.get(authAddKey)
|
|
11247
11644
|
if (!existing) {
|
|
11248
11645
|
await switchroomReply(ctx, "<i>No pending <code>/auth add</code> flow in this chat.</i>", { html: true })
|
|
11249
11646
|
return
|
|
11250
11647
|
}
|
|
11251
11648
|
cancelAccountAuthSession(existing)
|
|
11252
|
-
pendingAuthAddFlows.delete(
|
|
11649
|
+
pendingAuthAddFlows.delete(authAddKey)
|
|
11253
11650
|
await switchroomReply(ctx, "Cancelled.", { html: true })
|
|
11254
11651
|
return
|
|
11255
11652
|
}
|
|
11256
11653
|
// parsed.kind === 'add'
|
|
11257
|
-
if (pendingAuthAddFlows.has(
|
|
11654
|
+
if (pendingAuthAddFlows.has(authAddKey)) {
|
|
11258
11655
|
await switchroomReply(
|
|
11259
11656
|
ctx,
|
|
11260
11657
|
"<i>An <code>/auth add</code> flow is already in progress for this chat. " +
|
|
@@ -11265,7 +11662,7 @@ bot.command("auth", async ctx => {
|
|
|
11265
11662
|
}
|
|
11266
11663
|
try {
|
|
11267
11664
|
const { loginUrl, scratchDir, child } = await startAccountAuthSession(parsed.label)
|
|
11268
|
-
pendingAuthAddFlows.set(
|
|
11665
|
+
pendingAuthAddFlows.set(authAddKey, {
|
|
11269
11666
|
label: parsed.label,
|
|
11270
11667
|
scratchDir,
|
|
11271
11668
|
child,
|
|
@@ -11822,6 +12219,7 @@ async function performVaultAccessApproval(
|
|
|
11822
12219
|
operatorId: senderId,
|
|
11823
12220
|
})
|
|
11824
12221
|
const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
|
|
12222
|
+
if (delivered) markClaudeBusyForInbound(synthetic)
|
|
11825
12223
|
process.stderr.write(
|
|
11826
12224
|
`telegram gateway: vault_grant_approved injection agent=${pending.agent} ` +
|
|
11827
12225
|
`key=${pending.key} stage=${stageId} delivered=${delivered}\n`,
|
|
@@ -11901,6 +12299,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
11901
12299
|
operatorId: senderId,
|
|
11902
12300
|
})
|
|
11903
12301
|
const denyDelivered = ipcServer.sendToAgent(pending.agent, denyInbound)
|
|
12302
|
+
if (denyDelivered) markClaudeBusyForInbound(denyInbound)
|
|
11904
12303
|
process.stderr.write(
|
|
11905
12304
|
`telegram gateway: vault_grant_denied injection agent=${pending.agent} ` +
|
|
11906
12305
|
`key=${pending.key} stage=${stageId} delivered=${denyDelivered}\n`,
|
|
@@ -12051,6 +12450,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
12051
12450
|
operatorId: senderId,
|
|
12052
12451
|
})
|
|
12053
12452
|
const dDelivered = ipcServer.sendToAgent(pending.agent, discardInbound)
|
|
12453
|
+
if (dDelivered) markClaudeBusyForInbound(discardInbound)
|
|
12054
12454
|
process.stderr.write(
|
|
12055
12455
|
`telegram gateway: vault_save_discarded injection agent=${pending.agent} ` +
|
|
12056
12456
|
`key=${pending.key} stage=${stageId} delivered=${dDelivered}\n`,
|
|
@@ -12174,6 +12574,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
12174
12574
|
reason: failReason,
|
|
12175
12575
|
})
|
|
12176
12576
|
const fDelivered = ipcServer.sendToAgent(pending.agent, failInbound)
|
|
12577
|
+
if (fDelivered) markClaudeBusyForInbound(failInbound)
|
|
12177
12578
|
process.stderr.write(
|
|
12178
12579
|
`telegram gateway: vault_save_failed injection agent=${pending.agent} ` +
|
|
12179
12580
|
`key=${pending.key} stage=${stageId} delivered=${fDelivered}\n`,
|
|
@@ -12203,6 +12604,7 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
12203
12604
|
operatorId: senderId,
|
|
12204
12605
|
})
|
|
12205
12606
|
const okDelivered = ipcServer.sendToAgent(pending.agent, okInbound)
|
|
12607
|
+
if (okDelivered) markClaudeBusyForInbound(okInbound)
|
|
12206
12608
|
process.stderr.write(
|
|
12207
12609
|
`telegram gateway: vault_save_completed injection agent=${pending.agent} ` +
|
|
12208
12610
|
`key=${pending.key} stage=${stageId} delivered=${okDelivered}\n`,
|
|
@@ -13029,7 +13431,14 @@ async function handleOperatorEventCallback(ctx: Context, data: string): Promise<
|
|
|
13029
13431
|
parseMode: 'HTML',
|
|
13030
13432
|
synthInbound: async () => {
|
|
13031
13433
|
await runSwitchroomAuthCommand(ctx, ['auth', 'reauth', agent], `auth reauth ${agent}`)
|
|
13032
|
-
|
|
13434
|
+
// PR3 supergroup-mode: key by (chat, thread) so an OAuth code
|
|
13435
|
+
// pasted into a different topic isn't mistakenly intercepted
|
|
13436
|
+
// as this flow's reauth code.
|
|
13437
|
+
const reauthThreadId = ctx.callbackQuery?.message?.message_thread_id
|
|
13438
|
+
pendingReauthFlows.set(
|
|
13439
|
+
chatKey(String(ctx.chat!.id), reauthThreadId ?? null) as string,
|
|
13440
|
+
{ agent, startedAt: Date.now() },
|
|
13441
|
+
)
|
|
13033
13442
|
},
|
|
13034
13443
|
})
|
|
13035
13444
|
return
|
|
@@ -13565,14 +13974,16 @@ bot.command('logs', async ctx => {
|
|
|
13565
13974
|
try { assertSafeAgentName(name) } catch { await switchroomReply(ctx, 'Invalid agent name.'); return }
|
|
13566
13975
|
const lines = linesArg ? parseInt(linesArg, 10) : 20
|
|
13567
13976
|
const lineCount = isNaN(lines) || lines < 1 ? 20 : Math.min(lines, 200)
|
|
13568
|
-
|
|
13977
|
+
// PR5 — heavy-output → admin alias in supergroup mode (CPO #4).
|
|
13978
|
+
await runSwitchroomCommand(ctx, ['agent', 'logs', name, '--lines', String(lineCount)], `logs ${name}`, 'heavy')
|
|
13569
13979
|
})
|
|
13570
13980
|
|
|
13571
13981
|
bot.command('memory', async ctx => {
|
|
13572
13982
|
if (!isAuthorizedSender(ctx)) return
|
|
13573
13983
|
const query = ctx.match?.trim()
|
|
13574
13984
|
if (!query) { await switchroomReply(ctx, 'Usage: /memory <search query>'); return }
|
|
13575
|
-
|
|
13985
|
+
// PR5 — heavy-output → admin alias in supergroup mode (CPO #4).
|
|
13986
|
+
await runSwitchroomCommand(ctx, ['memory', 'search', query], 'memory search', 'heavy')
|
|
13576
13987
|
})
|
|
13577
13988
|
|
|
13578
13989
|
bot.command('issues', async ctx => {
|
|
@@ -14194,6 +14605,7 @@ bot.on('callback_query:data', async ctx => {
|
|
|
14194
14605
|
// by onClientRegistered) makes the "queued" promise real.
|
|
14195
14606
|
const selfAgentBtn = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
14196
14607
|
const btnDelivered = ipcServer.sendToAgent(selfAgentBtn, inboundMsg)
|
|
14608
|
+
if (btnDelivered) markClaudeBusyForInbound(inboundMsg)
|
|
14197
14609
|
if (!btnDelivered) {
|
|
14198
14610
|
pendingInboundBuffer.push(selfAgentBtn, inboundMsg)
|
|
14199
14611
|
// No registered bridge — the agent's mid-restart. Tell the user
|
|
@@ -15078,6 +15490,7 @@ function flushReactionBatch(batch: ReactionBatch): void {
|
|
|
15078
15490
|
meta,
|
|
15079
15491
|
}
|
|
15080
15492
|
const delivered = ipcServer.sendToAgent(agentName, inbound)
|
|
15493
|
+
if (delivered) markClaudeBusyForInbound(inbound)
|
|
15081
15494
|
process.stderr.write(
|
|
15082
15495
|
`telegram gateway: reactions.dispatch agent=${agentName} chat=${batch.chatId} ` +
|
|
15083
15496
|
`count=${batch.reactions.length} batched=${batch.batched} delivered=${delivered}\n`,
|