switchroom 0.14.7 → 0.14.9

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.
@@ -53,15 +53,7 @@ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
53
53
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
54
54
  import { StatusReactionController } from '../status-reactions.js'
55
55
  import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
56
- import { allocateDraftId } from '../draft-transport.js'
57
- import {
58
- makeEmptyActivityState,
59
- registerAndRender,
60
- describeToolUse,
61
- appendActivityLine,
62
- appendActivityLabel,
63
- type ActivityState,
64
- } from '../tool-activity-summary.js'
56
+ import { appendActivityLabel } from '../tool-activity-summary.js'
65
57
  import { toolLabel } from '../tool-labels.js'
66
58
  import { createTypingWrapper } from '../typing-wrap.js'
67
59
  import { type DraftStreamHandle } from '../draft-stream.js'
@@ -252,7 +244,7 @@ import { injectSlashCommand as injectSlashCommandImpl } from '../../src/agents/i
252
244
  import { handleInjectCommand } from './inject-handler.js'
253
245
  import { type BannerState } from '../slot-banner.js'
254
246
  import { refreshBanner } from '../slot-banner-driver.js'
255
- import { loadConfig as loadSwitchroomConfig } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
247
+ import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
256
248
  import { resolveOutboundTopic as resolveOutboundTopicHelper, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
257
249
  import { readTurnUsages } from '../../src/agents/perf.js'
258
250
  import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
@@ -369,6 +361,7 @@ import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.
369
361
  import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
370
362
  import { summarizeToolForTitle, formatPermissionCardBody } from '../permission-title.js'
371
363
  import { resolveAlwaysAllowRule, isRulePersisted } from '../permission-rule.js'
364
+ import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
372
365
  import {
373
366
  readClaudeJsonOverage,
374
367
  evaluateCreditState,
@@ -1274,6 +1267,11 @@ const progressUpdateTurnCount = new Map<string, number>()
1274
1267
  type CurrentTurn = {
1275
1268
  sessionChatId: string
1276
1269
  sessionThreadId: number | undefined
1270
+ // Inbound message id this turn answers. Anchors the activity feed's
1271
+ // native reply-quote (reply_parameters) so the user's question renders
1272
+ // as a quoted header on the feed message. Null for synthesized turns
1273
+ // (cron/handback) that have no originating inbound message.
1274
+ sourceMessageId: number | null
1277
1275
  startedAt: number
1278
1276
  gatewayReceiveAt: number
1279
1277
  replyCalled: boolean
@@ -1347,21 +1345,14 @@ type CurrentTurn = {
1347
1345
  // repeats until the pending matches the last-sent.
1348
1346
  // Result: at most one Telegram call in flight at a time; the
1349
1347
  // final state always lands.
1350
- toolActivity: ActivityState
1351
1348
  activityMessageId: number | null
1352
- // Draft-transport id when the activity summary is streamed via
1353
- // sendMessageDraft (DM-only, no thread). Each call to
1354
- // sendMessageDraft(chat, draftId, text) REPLACES the draft text —
1355
- // simpler than send+edit. Cleared by `clearActivitySummary` (which
1356
- // sends an empty draft) when the model's reply takes over.
1357
- activityDraftId: number | null
1358
1349
  activityInFlight: Promise<void> | null
1359
1350
  activityPendingRender: string | null
1360
1351
  activityLastSentRender: string | null
1361
- // Draft-mirror Phase 2: accumulating friendly-action feed for this turn
1362
- // (DRAFT_MIRROR only). Each non-surface tool_use appends a line via
1363
- // `appendActivityLine`; the feed renders as a capped chronological list
1364
- // in the ephemeral draft and clears on reply. Reset per turn.
1352
+ // Accumulating friendly-action feed for this turn. Each non-surface
1353
+ // tool_label appends a line via `appendActivityLabel`; the feed renders
1354
+ // (via `renderActivityFeed`) as a capped chronological list into the
1355
+ // in-place edited activity message and clears on reply. Reset per turn.
1365
1356
  mirrorLines: string[]
1366
1357
  // Issue #195 — answer-lane streaming. Lazily created on the first text
1367
1358
  // event of a turn (once enough text has accumulated, the stream itself
@@ -2282,6 +2273,27 @@ const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
2282
2273
  const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number }>()
2283
2274
  const PERMISSION_TTL_MS = 10 * 60_000
2284
2275
 
2276
+ // #1977 — single-tap correlation for the durable "🔁 Always allow"
2277
+ // flow. When the gateway dispatches a `config_propose_edit` to hostd in
2278
+ // response to an operator tap, hostd calls BACK asking for operator
2279
+ // approval. We pre-register the (agent, rule) pair here keyed
2280
+ // `${agentName}::${rule}` so that callback auto-approves WITHOUT a
2281
+ // second card. Forge-resistance: the auto-resolve match requires the
2282
+ // rule the inbound diff ADDS (via extractAddedAllowRule) to equal a
2283
+ // rule the gateway itself just queued — a forged edit touching any
2284
+ // other field finds no entry and falls through to a real operator card.
2285
+ // Single-shot (deleted on match) + 30s TTL sweep so a stale correlation
2286
+ // can't be replayed.
2287
+ const pendingAlwaysAllowCorrelations = new Map<string, { agentName: string; rule: string; unifiedDiff: string; createdAt: number }>()
2288
+ const ALWAYS_ALLOW_CORRELATION_TTL_MS = 30_000
2289
+ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
2290
+ for (const [key, entry] of pendingAlwaysAllowCorrelations) {
2291
+ if (now - entry.createdAt > ALWAYS_ALLOW_CORRELATION_TTL_MS) {
2292
+ pendingAlwaysAllowCorrelations.delete(key)
2293
+ }
2294
+ }
2295
+ }
2296
+
2285
2297
  // `ask_user` MCP tool — open prompts awaiting a user button-tap.
2286
2298
  // Keyed by askId (8 hex chars from generateAskId). Each entry holds
2287
2299
  // the deferred promise that resolves the originating tool call, the
@@ -3233,25 +3245,16 @@ const ANSWER_STREAM_VISIBLE_ENABLED = (() => {
3233
3245
  return true
3234
3246
  })()
3235
3247
 
3236
- // Draft-mirror preview (RFC docs/rfcs/draft-mirror-preview.md), Phase 1.
3237
- // When enabled, the model's prose narration streams into the ephemeral
3238
- // compose-area draft (sendMessageDraft) instead of a visible real
3239
- // message a live "what's it doing" preview that clears when the
3240
- // reply lands. Default OFF (canary flag). When on it (a) forces the
3241
- // answer-stream onto draft transport regardless of
3242
- // ANSWER_STREAM_VISIBLE_ENABLED, and (b) suppresses the activity-summary
3243
- // tool-count draft so the two don't collide on the single per-chat
3244
- // draft slot. Delivery on a no-reply turn is owned by turn-flush
3245
- // (decideTurnFlush → capturedText fresh send), NOT answer-stream
3246
- // materialize() — which is dead on the draft-only path (streamMsgId
3247
- // stays null, so its turn-end gate is false). Kill switch:
3248
- // SWITCHROOM_DRAFT_MIRROR unset/0/false/off/no.
3249
- const DRAFT_MIRROR_ENABLED = (() => {
3250
- const raw = process.env.SWITCHROOM_DRAFT_MIRROR
3251
- if (raw == null) return false
3252
- const v = raw.trim().toLowerCase()
3253
- return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
3254
- })()
3248
+ // Activity feed. The gateway streams a live "what it's doing" tool-activity
3249
+ // feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
3250
+ // call (flush-independent, so it stays real-time on fast/clustered-tool
3251
+ // turns); each label appends to `turn.mirrorLines`, and `renderActivityFeed`
3252
+ // renders the capped list into an in-place EDITED message (sendMessage +
3253
+ // editMessageText) anchored as a native reply-quote to the user's question.
3254
+ // The feed clears on the first reply (hand-off to the answer) and again at
3255
+ // turn_end (the no-reply safety net). It does NOT touch the answer-stream's
3256
+ // draft/visible lane the two render on separate surfaces, so they never
3257
+ // collide.
3255
3258
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3256
3259
  const progressDriver: any = null
3257
3260
  const unpinProgressCardForChat: ((chatId: string, threadId: number | undefined) => void) | null = null
@@ -4572,6 +4575,32 @@ const ipcServer: IpcServer = createIpcServer({
4572
4575
  },
4573
4576
  log: (m) =>
4574
4577
  process.stderr.write(`telegram gateway: config-approval — ${m}\n`),
4578
+ // #1977 single-tap correlation: auto-approve a config edit the
4579
+ // gateway itself just queued in response to an operator tap on
4580
+ // the "🔁 Always allow" permission card. Forge-resistant — the
4581
+ // match requires the rule the diff ADDS to equal a rule the
4582
+ // gateway queued; an agent-forged edit touching any other field
4583
+ // finds no entry and falls through to a real operator card.
4584
+ tryAutoResolve: (msg) => {
4585
+ sweepStaleAlwaysAllowCorrelations()
4586
+ // `extractAddedAllowRule` only locates a CANDIDATE entry by the
4587
+ // rule token — it is shape-based, not YAML-location-aware, so it
4588
+ // is NOT the security gate. The gate is an EXACT byte-match of
4589
+ // the incoming diff against the diff the gateway itself
4590
+ // synthesized and queued. A forged config_propose_edit (the same
4591
+ // consented token placed under `deny:`/`secrets:`, a different
4592
+ // field, or any other byte difference) won't match → falls
4593
+ // through to a real operator approval card.
4594
+ const added = extractAddedAllowRule(msg.unifiedDiff)
4595
+ if (!added) return null
4596
+ const key = `${msg.agentName}::${added}`
4597
+ const entry = pendingAlwaysAllowCorrelations.get(key)
4598
+ if (entry && entry.unifiedDiff === msg.unifiedDiff) {
4599
+ pendingAlwaysAllowCorrelations.delete(key)
4600
+ return 'approve'
4601
+ }
4602
+ return null
4603
+ },
4575
4604
  })
4576
4605
  },
4577
4606
 
@@ -6883,19 +6912,12 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
6883
6912
  * `turn.activityInFlight`; while set, new tool_uses only update
6884
6913
  * `turn.activityPendingRender` and return).
6885
6914
  *
6886
- * Transport priority (mirrors the existing answer-stream pattern):
6887
- *
6888
- * 1. DM with no thread AND sendMessageDraft API available
6889
- * DRAFT TRANSPORT. Each call REPLACES the draft text (no
6890
- * edit-in-place needed); the user sees a live preview in their
6891
- * Telegram compose area as the agent works. When the model's
6892
- * reply tool lands, `clearActivitySummary` sends an empty draft
6893
- * to wipe it — only the real reply persists.
6894
- *
6895
- * 2. Anything else (forum topic, draft API absent) → fall through
6896
- * to sendMessage + editMessageText. The activity message is a
6897
- * real chat message; `clearActivitySummary` deletes it when the
6898
- * reply tool takes over.
6915
+ * Transport: a single in-place edited message. The first render does
6916
+ * `sendMessage` (capturing `turn.activityMessageId`); subsequent renders
6917
+ * `editMessageText` that id, so the summary accumulates in place without
6918
+ * retyping the whole block. `clearActivitySummary` deletes the message
6919
+ * when the reply tool takes over. Works in DMs, groups, and forum topics
6920
+ * alike (forum topics pass message_thread_id).
6899
6921
  *
6900
6922
  * The drain holds a reference to `turn`, so a turn-swap mid-drain
6901
6923
  * doesn't corrupt the next turn's atom — late writes land on the
@@ -6906,29 +6928,28 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
6906
6928
  while (turn.activityPendingRender !== turn.activityLastSentRender) {
6907
6929
  const target = turn.activityPendingRender
6908
6930
  if (target == null) break
6909
- // Escape before wrapping in <i> + parse_mode HTML. The legacy
6910
- // verb-count summaries were safe ASCII, but the draft-mirror's
6911
- // describeToolUse content (file names, Bash descriptions, search
6912
- // queries) can contain <, >, & — which would break HTML parsing
6913
- // and surface literal tags (the exact #1942 bug class).
6914
- const html = `<i>${escapeHtmlForTg(target)}</i>`
6931
+ // `renderActivityFeed` already emitted ready Telegram HTML with per-line
6932
+ // markup (<b>→ current</b> / <i>✓ done</i>) and escaped each label's
6933
+ // <,>,& itself (#1942 class) send verbatim, do NOT re-escape or
6934
+ // re-wrap (double-escaping would surface literal tags).
6935
+ const html = target
6915
6936
  const chat = turn.sessionChatId
6916
6937
  const thread = turn.sessionThreadId
6917
- // sendMessageDraft doesn't support forum threads.
6918
- const useDraft = turn.isDm && thread == null && sendMessageDraftFn != null
6938
+ // Native reply-quote: anchor the feed message to the user's question so
6939
+ // it renders as a quoted header (reply_parameters renders on a real
6940
+ // message; edits preserve it). allow_sending_without_reply so a deleted
6941
+ // source can't drop the send.
6942
+ const replyAnchor = turn.sourceMessageId != null
6943
+ ? { reply_parameters: { message_id: turn.sourceMessageId, allow_sending_without_reply: true } }
6944
+ : {}
6919
6945
  try {
6920
- if (useDraft) {
6921
- if (turn.activityDraftId == null) {
6922
- turn.activityDraftId = allocateDraftId()
6923
- }
6924
- const draftId = turn.activityDraftId
6925
- await sendMessageDraftFn!(chat, draftId, html, { parse_mode: 'HTML' })
6926
- } else if (turn.activityMessageId == null) {
6946
+ if (turn.activityMessageId == null) {
6927
6947
  const sent = await robustApiCall(
6928
6948
  () => bot.api.sendMessage(chat, html, {
6929
6949
  ...(thread != null ? { message_thread_id: thread } : {}),
6930
6950
  parse_mode: 'HTML',
6931
6951
  disable_notification: true,
6952
+ ...replyAnchor,
6932
6953
  }),
6933
6954
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.send' },
6934
6955
  )
@@ -6958,30 +6979,20 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
6958
6979
  /**
6959
6980
  * Clear the activity summary when the model's reply tool takes over
6960
6981
  * as the authoritative surface. Awaits any in-flight render so we
6961
- * don't race a stale write against the clear, then either sends an
6962
- * empty draft (clears the compose-area preview) or deletes the
6963
- * persisted message. Idempotent + best-effort — failure stderr-logs
6964
- * but does not block.
6982
+ * don't race a stale write against the clear, then deletes the activity
6983
+ * message. Idempotent + best-effort — failure stderr-logs but does not
6984
+ * block.
6965
6985
  *
6966
- * Called from `case 'tool_use'` the moment we see a Telegram reply
6967
- * tool fire, so the user sees the real reply land in the same beat
6968
- * the summary disappears.
6986
+ * Called on the first reply (hand-off to the answer) and again at
6987
+ * turn_end (the no-reply safety net), so the user sees the real reply
6988
+ * land in the same beat the summary disappears.
6969
6989
  */
6970
6990
  function clearActivitySummary(turn: CurrentTurn): void {
6971
6991
  const chat = turn.sessionChatId
6972
6992
  const thread = turn.sessionThreadId
6973
6993
  const inFlight = turn.activityInFlight ?? Promise.resolve()
6974
6994
  void inFlight.then(async () => {
6975
- if (turn.activityDraftId != null && sendMessageDraftFn != null) {
6976
- const draftId = turn.activityDraftId
6977
- turn.activityDraftId = null
6978
- try {
6979
- // Empty text → Telegram clears the draft.
6980
- await sendMessageDraftFn(chat, draftId, '', undefined)
6981
- } catch (err) {
6982
- process.stderr.write(`telegram gateway: activity-summary draft-clear failed: ${err}\n`)
6983
- }
6984
- } else if (turn.activityMessageId != null) {
6995
+ if (turn.activityMessageId != null) {
6985
6996
  const id = turn.activityMessageId
6986
6997
  turn.activityMessageId = null
6987
6998
  try {
@@ -7040,6 +7051,9 @@ function handleSessionEvent(ev: SessionEvent): void {
7040
7051
  const next: CurrentTurn = {
7041
7052
  sessionChatId: ev.chatId,
7042
7053
  sessionThreadId: ev.threadId != null ? Number(ev.threadId) : undefined,
7054
+ sourceMessageId: ev.messageId != null && /^\d+$/.test(ev.messageId)
7055
+ ? Number(ev.messageId)
7056
+ : null,
7043
7057
  startedAt,
7044
7058
  gatewayReceiveAt: startedAt,
7045
7059
  replyCalled: false,
@@ -7053,9 +7067,7 @@ function handleSessionEvent(ev: SessionEvent): void {
7053
7067
  lastAssistantMsgId: null,
7054
7068
  lastAssistantDone: false,
7055
7069
  toolCallCount: 0,
7056
- toolActivity: makeEmptyActivityState(),
7057
7070
  activityMessageId: null,
7058
- activityDraftId: null,
7059
7071
  activityInFlight: null,
7060
7072
  activityPendingRender: null,
7061
7073
  activityLastSentRender: null,
@@ -7192,58 +7204,20 @@ function handleSessionEvent(ev: SessionEvent): void {
7192
7204
  turn.orphanedReplyTimeoutId = null
7193
7205
  }
7194
7206
  // The model's real reply takes over as the authoritative
7195
- // surface. Clear the activity summaryfor drafts, send an
7196
- // empty draft to wipe the compose-area preview; for persisted
7197
- // messages, delete. The user sees the real reply land in the
7198
- // same beat the summary disappears.
7199
- // Legacy (flag-off): the activity summary clears on the first
7200
- // reply — it was a one-shot "what I did" line. DRAFT_MIRROR keeps
7201
- // the live feed running through mid-turn replies and clears it at
7202
- // turn_end instead, so an early reply doesn't wipe the stream
7203
- // (the fast-turn determinism fix).
7204
- if (wasFirstReply && !DRAFT_MIRROR_ENABLED) {
7207
+ // surface, so delete the activity feed message the user
7208
+ // sees the real reply land in the same beat the feed
7209
+ // disappears. turn_end is the no-reply safety net.
7210
+ if (wasFirstReply) {
7205
7211
  clearActivitySummary(turn)
7206
7212
  }
7207
7213
  }
7208
- // Tool-intent surface companion to the PreToolUse ack-first gate
7209
- // (#1921). On the FIRST non-reply tool_use of a turn AND only when
7210
- // Tool-activity summary same shape Claude Code natively renders
7211
- // in its CLI/chat UI ("Ran 5 commands, read a file"). The gateway
7212
- // accumulates non-reply tool_use events into `turn.toolActivity`
7213
- // and sends ONE Telegram message that edits in place as more tools
7214
- // land. Stops editing once the model calls `reply` — the summary
7215
- // line stays as the final state. No model-side prompting; no per-
7216
- // tool labels. Just surface what's already in the stream.
7217
- //
7218
- // Single-flight coalescing (PR #1926 review): modern Claude emits
7219
- // multiple tool_uses in a synchronous burst (parallel Reads,
7220
- // Bashes, etc.). All would otherwise race past the message-id
7221
- // capture and produce N messages. Pattern mirrors answer-stream:
7222
- // update `activityPendingRender` synchronously here; a single
7223
- // worker promise drains the pending state, sending or editing
7224
- // exactly once at a time and re-running until pending matches
7225
- // the last-sent. Captures `turn` so a late drain after turn-swap
7226
- // can't corrupt the next turn's atom.
7227
- // Flag OFF (default): the legacy generic verb-count summary
7228
- // ("Ran 5 commands") via registerAndRender — byte-identical to
7229
- // pre-draft-mirror behavior, cleared on first reply.
7230
- //
7231
- // DRAFT_MIRROR: the draft is NOT driven from this (flush-gated)
7232
- // tool_use event — it's driven by the real-time `tool_label` event
7233
- // (PreToolUse sidecar, fires at tool-call time regardless of when
7234
- // claude flushes the transcript). See `case 'tool_label'`. That's
7214
+ // The live activity feed is driven by the real-time `tool_label`
7215
+ // event (PreToolUse sidecar) rather than this flush-gated tool_use
7216
+ // pathsee `case 'tool_label'`. The sidecar fires at tool-call
7217
+ // time regardless of when claude flushes the transcript, which is
7235
7218
  // the determinism fix: on a fast/clustered-tool turn the JSONL
7236
7219
  // tool_use rows aren't on disk until ~turn-end, so sourcing the
7237
- // draft here lost the feed; the sidecar is flush-independent.
7238
- if (!DRAFT_MIRROR_ENABLED && !turn.replyCalled && !isTelegramSurfaceTool(name)) {
7239
- const rendered = registerAndRender(turn.toolActivity, name)
7240
- if (rendered != null) {
7241
- turn.activityPendingRender = rendered
7242
- if (turn.activityInFlight == null) {
7243
- turn.activityInFlight = drainActivitySummary(turn)
7244
- }
7245
- }
7246
- }
7220
+ // feed here would lose them.
7247
7221
  if (!ctrl) return
7248
7222
  if (isTelegramSurfaceTool(name)) return
7249
7223
  ctrl.setTool(name)
@@ -7253,21 +7227,26 @@ function handleSessionEvent(ev: SessionEvent): void {
7253
7227
  return
7254
7228
  }
7255
7229
  case 'tool_label': {
7256
- // DRAFT_MIRROR real-time driver. The PreToolUse hook wrote this
7230
+ // Real-time activity-feed driver. The PreToolUse hook wrote this
7257
7231
  // label synchronously at tool-call time; the sidecar surfaced it
7258
7232
  // here (~250ms) independent of the transcript flush. Accumulate it
7259
- // into the live feed and update the ephemeral draft — this is what
7260
- // makes the draft deterministic on fast/clustered-tool turns where
7261
- // the JSONL tool_use rows arrive too late.
7262
- if (!DRAFT_MIRROR_ENABLED) return
7233
+ // into the live feed and edit the activity message in place — this
7234
+ // is what makes the feed deterministic on fast/clustered-tool turns
7235
+ // where the JSONL tool_use rows arrive too late.
7263
7236
  const turn = currentTurn
7264
7237
  if (turn == null) return
7265
7238
  // Surface tools (reply/stream_reply/react) are the conversation, not
7266
7239
  // activity — the hook labels them ("Replying"), so filter by name.
7267
7240
  if (isTelegramSurfaceTool(ev.toolName)) return
7268
- // Unlike the legacy tool_use path, do NOT gate on replyCalled the
7269
- // whole point is to show activity even when a reply raced ahead of
7270
- // the (lagged) transcript. The feed clears at turn_end.
7241
+ // Stop feeding once the reply has landed. The first reply is the
7242
+ // hand-off: `clearActivitySummary` deletes the feed so the answer is
7243
+ // the authoritative surface (the validated clean hand-off). Without
7244
+ // this gate a tool called after the reply would re-`sendMessage` a
7245
+ // fresh feed message below the answer — a delete-then-resend flicker.
7246
+ // Safe ordering: `tool_label` is real-time (PreToolUse, ~250ms) while
7247
+ // `replyCalled` is set from the lagged reply tool_use, so a genuinely
7248
+ // pre-reply label virtually always arrives before the flag flips.
7249
+ if (turn.replyCalled) return
7271
7250
  const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
7272
7251
  if (rendered != null) {
7273
7252
  turn.activityPendingRender = rendered
@@ -7547,12 +7526,13 @@ function handleSessionEvent(ev: SessionEvent): void {
7547
7526
  clearTimeout(turn.orphanedReplyTimeoutId)
7548
7527
  turn.orphanedReplyTimeoutId = null
7549
7528
  }
7550
- // DRAFT_MIRROR: the live activity feed runs through the whole turn
7551
- // (it is NOT cleared on the first reply, unlike the legacy summary)
7552
- // so an early/mid-turn reply can't wipe it. Clear it here, at the
7553
- // real end of the turn the ephemeral compose-area draft goes away
7554
- // once the work is actually done.
7555
- if (DRAFT_MIRROR_ENABLED && turn != null) {
7529
+ // Clear the activity feed at the real end of the turn. This is the
7530
+ // no-reply safety net a turn that ends without ever calling reply
7531
+ // (the answer is delivered by turn-flush / silent-end) still has its
7532
+ // feed removed. On a normal turn the feed was already cleared at the
7533
+ // first reply (the hand-off); clearActivitySummary is idempotent, so
7534
+ // the second call is a no-op.
7535
+ if (turn != null) {
7556
7536
  clearActivitySummary(turn)
7557
7537
  }
7558
7538
  // #549 fix — flush any pending preamble BEFORE the answer stream is
@@ -15266,13 +15246,20 @@ bot.on('callback_query:data', async ctx => {
15266
15246
  }
15267
15247
 
15268
15248
  if (behavior === 'always') {
15269
- // "🔁 Always allow" — write the resolved rule into the agent's
15270
- // tools.allow in switchroom.yaml via the existing `agent grant`
15271
- // CLI verb, then approve the in-flight request. Reconcile updates
15272
- // settings.json so future SESSIONS skip the popup; the in-flight
15273
- // turn already has its settings.json loaded so the rule won't
15274
- // suppress later prompts on this same turn operator restarts
15275
- // the agent if they want full immediate effect.
15249
+ // "🔁 Always allow" (#1977) persist the resolved rule into the
15250
+ // agent's tools.allow in the DURABLE host config. The old path
15251
+ // shelled `switchroom agent grant` which wrote
15252
+ // /state/config/switchroom.yaml but that path is bind-mounted
15253
+ // READ-ONLY into agent containers, so the write silently no-op'd.
15254
+ // Durable host-config writes only land via the host-side hostd
15255
+ // daemon's `config_propose_edit` flow. We:
15256
+ // 1. dispatch the in-flight Allow verdict IMMEDIATELY (turn must
15257
+ // not block on the host round-trip);
15258
+ // 2. if hostd is not-configured → fall back to the legacy
15259
+ // `agent grant` + verify path (honest messaging only);
15260
+ // 3. otherwise synthesize a unified diff adding the rule to the
15261
+ // agent's tools.allow and send it to hostd, awaiting the
15262
+ // apply+reconcile result.
15276
15263
  const details = pendingPermissions.get(request_id)
15277
15264
  if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
15278
15265
  const rule = resolveAlwaysAllowRule(details.tool_name, details.input_preview)
@@ -15285,57 +15272,143 @@ bot.on('callback_query:data', async ctx => {
15285
15272
  await ctx.answerCallbackQuery({ text: 'Always-allow needs SWITCHROOM_AGENT_NAME — gateway is misconfigured.' }).catch(() => {})
15286
15273
  return
15287
15274
  }
15288
- let grantOk = false
15289
- let grantFailReason = ''
15290
- try {
15291
- // --no-restart: settings.json gets the new entry on the next
15292
- // reconcile but we don't bounce the agent mid-turn. Operator
15293
- // can restart manually if they want this rule live in this
15294
- // session; otherwise it kicks in next session.
15295
- switchroomExec(['agent', 'grant', agentName, rule.rule, '--no-restart'])
15296
- // Verify the rule actually landed in the resolved config guards
15297
- // against config-location-drift (gateway edited a yaml that isn't
15298
- // the durable source-of-truth, or the grant was a no-op). One
15299
- // fresh config read; cheap since this is a rare operator tap.
15275
+
15276
+ pendingPermissions.delete(request_id)
15277
+
15278
+ // (2) Dispatch the in-flight permission verdict IMMEDIATELY — before
15279
+ // any host round-trip so the turn never blocks on persistence.
15280
+ // We carry the resolved `rule` so the bridge caches it for the rest
15281
+ // of the session and auto-allows matching tool calls from sub-agents
15282
+ // (Task tool) + the parent without re-popping the prompt (#1138).
15283
+ // The rule is safe to cache regardless of whether the *durable*
15284
+ // write later succeeds it's the operator's explicit intent.
15285
+ dispatchPermissionVerdict({
15286
+ type: 'permission',
15287
+ requestId: request_id,
15288
+ behavior: 'allow',
15289
+ rule: rule.rule,
15290
+ })
15291
+
15292
+ // (3) Decide the persistence path. tryHostdDispatch returns
15293
+ // "not-configured" when host_control is disabled or the per-agent
15294
+ // socket is absent → legacy fallback.
15295
+ let durable = false
15296
+ let legacy = false
15297
+ let failReason = ''
15298
+ let editLockHint = false
15299
+
15300
+ const configEditDisabled = (msg: string): boolean =>
15301
+ msg.includes('E_CONFIG_EDIT_DISABLED')
15302
+
15303
+ const unifiedDiff = (() => {
15300
15304
  try {
15301
- const cfg = loadSwitchroomConfig()
15302
- const rawAgent = cfg.agents?.[agentName]
15303
- if (rawAgent) {
15304
- const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
15305
- const allowList: string[] = (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
15306
- if (isRulePersisted(allowList, rule.rule)) {
15307
- grantOk = true
15308
- process.stderr.write(
15309
- `telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName} (request_id=${request_id})\n`,
15310
- )
15311
- } else {
15312
- grantFailReason = `rule "${rule.rule}" not found in resolved tools.allow after write — config location may have drifted`
15313
- process.stderr.write(
15314
- `telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})\n`,
15315
- )
15316
- }
15305
+ const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
15306
+ const raw = readFileSync(cfgPath, 'utf8')
15307
+ return synthesizeAllowRuleDiff({ agentName, rule: rule.rule, configText: raw })
15308
+ } catch (err) {
15309
+ process.stderr.write(`telegram gateway: always-allow diff synth failed: ${(err as Error).message}\n`)
15310
+ return null
15311
+ }
15312
+ })()
15313
+
15314
+ const correlationKey = `${agentName}::${rule.rule}`
15315
+ try {
15316
+ if (unifiedDiff == null) {
15317
+ // Could not locate the agent block / read config → fall back to
15318
+ // the legacy grant path; its own verify will produce honest
15319
+ // messaging.
15320
+ legacy = true
15321
+ } else {
15322
+ // Pre-register the single-tap correlation so hostd's callback
15323
+ // (request_config_approval) auto-approves WITHOUT a second card.
15324
+ pendingAlwaysAllowCorrelations.set(correlationKey, { agentName, rule: rule.rule, unifiedDiff, createdAt: Date.now() })
15325
+ const req: HostdRequest = {
15326
+ v: 1,
15327
+ op: 'config_propose_edit',
15328
+ request_id: hostdRequestId('gw-always-allow'),
15329
+ args: {
15330
+ unified_diff: unifiedDiff,
15331
+ reason: `Operator 'always allow' for ${rule.label}`,
15332
+ target_path: '/state/config/switchroom.yaml',
15333
+ },
15334
+ }
15335
+ // config_propose_edit blocks on validate→approve→apply→reconcile,
15336
+ // so allow ~60s (well past the default 5s).
15337
+ const resp = await tryHostdDispatch(agentName, req, 60_000)
15338
+ if (resp === 'not-configured') {
15339
+ warnLegacySpawnIfHostdDisabled('always-allow')
15340
+ legacy = true
15341
+ } else if (resp.result === 'completed') {
15342
+ durable = true
15343
+ process.stderr.write(
15344
+ `telegram gateway: always-allow durable via hostd rule="${rule.rule}" agent=${agentName} (request_id=${request_id})\n`,
15345
+ )
15317
15346
  } else {
15318
- grantFailReason = `agent "${agentName}" not found in config after write`
15347
+ failReason = resp.error ?? `hostd ${resp.result}`
15348
+ if (configEditDisabled(failReason)) editLockHint = true
15319
15349
  process.stderr.write(
15320
- `telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})\n`,
15350
+ `telegram gateway: always-allow hostd FAILED: ${failReason} (request_id=${request_id})\n`,
15321
15351
  )
15322
15352
  }
15323
- } catch (verifyErr) {
15324
- grantFailReason = `config re-read failed: ${(verifyErr as Error).message}`
15325
- process.stderr.write(
15326
- `telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})\n`,
15327
- )
15328
15353
  }
15329
- } catch (err) {
15330
- grantFailReason = (err as Error).message
15331
- process.stderr.write(`telegram gateway: always-allow grant failed: ${grantFailReason}\n`)
15332
- }
15333
15354
 
15334
- pendingPermissions.delete(request_id)
15355
+ if (legacy) {
15356
+ // Legacy not-configured fallback — keep TODAY's behaviour:
15357
+ // shell `agent grant` (writes the host yaml only when the
15358
+ // gateway has a writable path) + verify the rule actually
15359
+ // landed. Honest messaging: "saved (legacy path)" on verify,
15360
+ // else the "did NOT save" warning.
15361
+ try {
15362
+ switchroomExec(['agent', 'grant', agentName, rule.rule, '--no-restart'])
15363
+ try {
15364
+ const cfg = loadSwitchroomConfig()
15365
+ const rawAgent = cfg.agents?.[agentName]
15366
+ if (rawAgent) {
15367
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
15368
+ const allowList: string[] = (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
15369
+ if (isRulePersisted(allowList, rule.rule)) {
15370
+ durable = true // legacy path verified — durable on this host shape
15371
+ process.stderr.write(
15372
+ `telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName} via legacy grant (request_id=${request_id})\n`,
15373
+ )
15374
+ } else {
15375
+ failReason = `rule "${rule.rule}" not found in resolved tools.allow after write — config location may have drifted`
15376
+ process.stderr.write(
15377
+ `telegram gateway: always-allow VERIFY FAILED: ${failReason} (request_id=${request_id})\n`,
15378
+ )
15379
+ }
15380
+ } else {
15381
+ failReason = `agent "${agentName}" not found in config after write`
15382
+ process.stderr.write(
15383
+ `telegram gateway: always-allow VERIFY FAILED: ${failReason} (request_id=${request_id})\n`,
15384
+ )
15385
+ }
15386
+ } catch (verifyErr) {
15387
+ failReason = `config re-read failed: ${(verifyErr as Error).message}`
15388
+ process.stderr.write(
15389
+ `telegram gateway: always-allow VERIFY FAILED: ${failReason} (request_id=${request_id})\n`,
15390
+ )
15391
+ }
15392
+ } catch (err) {
15393
+ failReason = (err as Error).message
15394
+ process.stderr.write(`telegram gateway: always-allow grant failed: ${failReason}\n`)
15395
+ }
15396
+ }
15397
+ } finally {
15398
+ // Single-shot correlation — drop it whether or not it was
15399
+ // consumed by hostd's callback, so it can never be replayed.
15400
+ pendingAlwaysAllowCorrelations.delete(correlationKey)
15401
+ }
15335
15402
 
15336
- const ackText = grantOk
15337
- ? `🔁 Always allow ${rule.label} for ${agentName}`
15338
- : `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`
15403
+ const ok = durable
15404
+ const legacyNote = legacy && durable
15405
+ const ackText = ok
15406
+ ? (legacyNote
15407
+ ? `🔁 Always allow ${rule.label} for ${agentName} (legacy path)`
15408
+ : `🔁 Always allow ${rule.label} for ${agentName}`)
15409
+ : (editLockHint
15410
+ ? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
15411
+ : `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
15339
15412
  // HTML-escape baseText — `ctx.callbackQuery.message.text` returns
15340
15413
  // entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
15341
15414
  // expanded permission card's `description` or `input_preview`
@@ -15346,37 +15419,22 @@ bot.on('callback_query:data', async ctx => {
15346
15419
  const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
15347
15420
  ? escapeHtmlForTg(sourceMsg.text)
15348
15421
  : ''
15349
- const editLabel = grantOk
15350
- ? `🔁 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName)} — restart agent for full effect`
15351
- : `⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`
15422
+ const editLabel = ok
15423
+ ? (legacyNote
15424
+ ? `🔁 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName)} saved (legacy path); restart agent for full effect`
15425
+ : `🔁 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName)} — saved; restart agent for full effect`)
15426
+ : (editLockHint
15427
+ ? `⚠️ <b>Allowed for now — "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.`
15428
+ : `⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`)
15352
15429
  // #1150 audit: route through finalizeCallback so the keyboard
15353
- // strips alongside the status-line edit. Pre-fix this called
15354
- // editMessageText without `reply_markup` so the Allow/Deny/Always
15355
- // buttons stayed tappable after the decision re-tap would re-
15356
- // fire the permission broadcast.
15430
+ // strips alongside the status-line edit. The in-flight verdict was
15431
+ // ALREADY dispatched above (independently of this host round-trip)
15432
+ // so the turn never blockedfinalizeCallback here only edits the
15433
+ // card; no synthInbound (would double-fire the verdict).
15357
15434
  await finalizeCallback(ctx, {
15358
15435
  ackText: ackText.slice(0, 200),
15359
15436
  newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
15360
15437
  parseMode: 'HTML',
15361
- // Forward approval for the in-flight request regardless — even
15362
- // if the yaml edit failed, the operator clearly meant "yes" so
15363
- // we honour the immediate decision and surface the failure as
15364
- // a hint in the chat.
15365
- //
15366
- // #1138: also carry the resolved `rule` so the bridge can cache
15367
- // it for the rest of the session and auto-allow matching tool
15368
- // calls from sub-agents (Task tool) and the parent without
15369
- // re-popping the prompt. Only set when the yaml edit succeeded —
15370
- // otherwise the rule may be unsafe to honour at scale and we
15371
- // fall back to single-use allow.
15372
- synthInbound: () => {
15373
- dispatchPermissionVerdict({
15374
- type: 'permission',
15375
- requestId: request_id,
15376
- behavior: 'allow',
15377
- ...(grantOk ? { rule: rule.rule } : {}),
15378
- })
15379
- },
15380
15438
  })
15381
15439
  return
15382
15440
  }
@@ -15418,7 +15476,9 @@ bot.on('callback_query:data', async ctx => {
15418
15476
  })
15419
15477
 
15420
15478
  // ─── Inbound message handlers ─────────────────────────────────────────────
15421
- bot.on('message:text', async ctx => { await handleInboundCoalesced(ctx, ctx.message.text, undefined) })
15479
+ bot.on('message:text', async ctx => {
15480
+ await handleInboundCoalesced(ctx, ctx.message.text, undefined)
15481
+ })
15422
15482
 
15423
15483
  bot.on('message:photo', async ctx => {
15424
15484
  const caption = ctx.message.caption ?? '(photo)'