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.
Files changed (39) hide show
  1. package/dist/agent-scheduler/index.js +399 -213
  2. package/dist/auth-broker/index.js +576 -237
  3. package/dist/cli/drive-write-pretool.mjs +28 -13
  4. package/dist/cli/ms-365-write-pretool.mjs +259 -0
  5. package/dist/cli/skill-validate-pretool.mjs +72 -72
  6. package/dist/cli/switchroom.js +3241 -1382
  7. package/dist/host-control/main.js +396 -276
  8. package/dist/vault/approvals/kernel-server.js +8266 -8142
  9. package/dist/vault/broker/server.js +2894 -2770
  10. package/package.json +1 -1
  11. package/profiles/_base/start.sh.hbs +17 -0
  12. package/profiles/_shared/telegram-style.md.hbs +2 -0
  13. package/skills/switchroom-status/SKILL.md +8 -6
  14. package/telegram-plugin/chat-lock.ts +87 -19
  15. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  16. package/telegram-plugin/dist/gateway/gateway.js +1283 -343
  17. package/telegram-plugin/dist/server.js +160 -160
  18. package/telegram-plugin/gateway/disconnect-flush.ts +32 -0
  19. package/telegram-plugin/gateway/gateway.ts +485 -72
  20. package/telegram-plugin/gateway/inbound-coalesce.ts +19 -6
  21. package/telegram-plugin/gateway/ipc-protocol.ts +37 -0
  22. package/telegram-plugin/gateway/ipc-server.ts +59 -0
  23. package/telegram-plugin/gateway/ms365-write-approval.test.ts +314 -0
  24. package/telegram-plugin/gateway/ms365-write-approval.ts +335 -0
  25. package/telegram-plugin/stream-reply-handler.ts +10 -8
  26. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +116 -0
  27. package/telegram-plugin/tests/inbound-coalesce.test.ts +20 -4
  28. package/telegram-plugin/tests/ipc-validator.test.ts +61 -0
  29. package/telegram-plugin/tests/outbound-ordering.test.ts +228 -0
  30. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +217 -0
  31. package/telegram-plugin/tests/slash-command-smart-split.test.ts +115 -0
  32. package/telegram-plugin/tests/typing-wrap.test.ts +65 -8
  33. package/telegram-plugin/typing-wrap.ts +43 -21
  34. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +35 -0
  35. package/vendor/hindsight-memory/scripts/recall.py +164 -4
  36. package/vendor/hindsight-memory/scripts/retain.py +52 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +42 -0
  38. package/vendor/hindsight-memory/scripts/tests/test_recall_topic_filter.py +139 -0
  39. 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
- stopTurnTypingLoop(chatIdOfChatKey(key as _ChatKey))
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
- if (activeTurnStartedAt.size === 0) {
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) => ipcServer.sendToAgent(selfAgentForFlush, 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
- if (activeTurnStartedAt.size === 0) {
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) => ipcServer.sendToAgent(selfAgentForFlush, 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
- const threadId = chatThreadMap.get(chatId);
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(chat_id: string, action: ChatAction = 'typing'): void {
1907
- stopTypingLoop(chat_id)
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(chat_id)
1918
- startTypingLoop(chat_id, action)
2010
+ typingRetryTimers.delete(key)
2011
+ startTypingLoop(chat_id, thread_id, action)
1919
2012
  }, typingBackoffMs)
1920
- typingRetryTimers.set(chat_id, retry)
2013
+ typingRetryTimers.set(key, retry)
1921
2014
  }
1922
2015
  },
1923
2016
  )
1924
2017
  }
1925
2018
  send()
1926
- typingIntervals.set(chat_id, setInterval(send, 4000))
2019
+ typingIntervals.set(key, setInterval(send, 4000))
1927
2020
  }
1928
2021
 
1929
- function stopTypingLoop(chat_id: string): void {
1930
- const iv = typingIntervals.get(chat_id)
1931
- if (iv) { clearInterval(iv); typingIntervals.delete(chat_id) }
1932
- const retry = typingRetryTimers.get(chat_id)
1933
- if (retry) { clearTimeout(retry); typingRetryTimers.delete(chat_id) }
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(chat_id, setInterval(send, 4000))
2050
+ turnTypingIntervals.set(key, setInterval(send, 4000))
1955
2051
  }
1956
2052
 
1957
- function stopTurnTypingLoop(chat_id: string): void {
1958
- const iv = turnTypingIntervals.get(chat_id)
1959
- if (iv) { clearInterval(iv); turnTypingIntervals.delete(chat_id) }
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)\n`,
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
- // allow-raw-bot-api: operator-event broadcast loop; opts has no message_thread_id
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) => ipcServer.sendToAgent(fbSelfAgent, 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
- // allow-raw-bot-api: permission-request keyboard fan-out; reply_markup + parse_mode only, no thread_id
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
- const turnInFlight = activeTurnStartedAt.size > 0;
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
- return { chatId: operator }
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
- return { chatId: operator }
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
- if (activeTurnStartedAt.size > 0) return false
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) => ipcServer.sendToAgent(selfAgent, 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
- startTypingLoop(stChatId, action)
6285
- setTimeout(() => stopTypingLoop(stChatId), 30000)
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(String(ctx.chat!.id), String(from.id))
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
- const turnInFlightAtReceipt = activeTurnStartedAt.size > 0
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
- const pendingAdd = pendingAuthAddFlows.get(chat_id)
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(chat_id)
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(chat_id)
8481
+ pendingAuthAddFlows.delete(interceptKey)
8216
8482
  }
8217
8483
 
8218
8484
  // Auth-code intercept
8219
- const pendingReauth = pendingReauthFlows.get(chat_id)
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(chat_id)
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(chat_id)
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
- startTurnTypingLoop(chat_id)
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: { html?: boolean; reply_markup?: SwitchroomReplyMarkup } = {},
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 threadId = resolveThreadId(chatId, ctx.message?.message_thread_id)
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: undefined, ackMsgId: undefined }
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: undefined, ackMsgId: undefined }
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(ctx: Context, args: string[], label: string): Promise<void> {
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`); return }
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
- await runSwitchroomCommand(ctx, ['update', '--status'], 'update --status')
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
- await runSwitchroomCommand(ctx, argv, `hostd audit${argv.length > 2 ? ' …' : ''}`)
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(chatId)
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(chatId)
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(chatId)) {
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(chatId, {
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
- pendingReauthFlows.set(String(ctx.chat!.id), { agent, startedAt: Date.now() })
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
- await runSwitchroomCommand(ctx, ['agent', 'logs', name, '--lines', String(lineCount)], `logs ${name}`)
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
- await runSwitchroomCommand(ctx, ['memory', 'search', query], 'memory search')
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`,