switchroom 0.14.6 → 0.14.8

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,7 +53,6 @@ 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
56
  import {
58
57
  makeEmptyActivityState,
59
58
  registerAndRender,
@@ -252,7 +251,7 @@ import { injectSlashCommand as injectSlashCommandImpl } from '../../src/agents/i
252
251
  import { handleInjectCommand } from './inject-handler.js'
253
252
  import { type BannerState } from '../slot-banner.js'
254
253
  import { refreshBanner } from '../slot-banner-driver.js'
255
- import { loadConfig as loadSwitchroomConfig } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
254
+ import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
256
255
  import { resolveOutboundTopic as resolveOutboundTopicHelper, type TopicRouterConfig as _OutboundRouterConfig } from '../../src/telegram/topic-router.js'
257
256
  import { readTurnUsages } from '../../src/agents/perf.js'
258
257
  import { decideProactiveCompact, initialCompactState, type CompactState } from './proactive-compact.js'
@@ -369,6 +368,7 @@ import { startIssuesWatcher, type IssuesWatcherHandle } from '../issues-watcher.
369
368
  import { list as listIssues, resolve as resolveIssue } from '../../src/issues/index.js'
370
369
  import { summarizeToolForTitle, formatPermissionCardBody } from '../permission-title.js'
371
370
  import { resolveAlwaysAllowRule, isRulePersisted } from '../permission-rule.js'
371
+ import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
372
372
  import {
373
373
  readClaudeJsonOverage,
374
374
  evaluateCreditState,
@@ -1274,6 +1274,11 @@ const progressUpdateTurnCount = new Map<string, number>()
1274
1274
  type CurrentTurn = {
1275
1275
  sessionChatId: string
1276
1276
  sessionThreadId: number | undefined
1277
+ // Inbound message id this turn answers. Anchors the activity feed's
1278
+ // native reply-quote (reply_parameters) so the user's question renders
1279
+ // as a quoted header on the feed message. Null for synthesized turns
1280
+ // (cron/handback) that have no originating inbound message.
1281
+ sourceMessageId: number | null
1277
1282
  startedAt: number
1278
1283
  gatewayReceiveAt: number
1279
1284
  replyCalled: boolean
@@ -1349,19 +1354,14 @@ type CurrentTurn = {
1349
1354
  // final state always lands.
1350
1355
  toolActivity: ActivityState
1351
1356
  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
1357
  activityInFlight: Promise<void> | null
1359
1358
  activityPendingRender: string | null
1360
1359
  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.
1360
+ // Accumulating friendly-action feed for this turn (DRAFT_MIRROR only).
1361
+ // Each non-surface tool_use appends a line via `appendActivityLine`; the
1362
+ // feed renders (via `renderActivityFeed`) as a capped chronological list
1363
+ // into the in-place edited activity message and clears on reply. Reset
1364
+ // per turn.
1365
1365
  mirrorLines: string[]
1366
1366
  // Issue #195 — answer-lane streaming. Lazily created on the first text
1367
1367
  // event of a turn (once enough text has accumulated, the stream itself
@@ -2282,6 +2282,27 @@ const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i
2282
2282
  const pendingPermissions = new Map<string, { tool_name: string; description: string; input_preview: string; startedAt: number }>()
2283
2283
  const PERMISSION_TTL_MS = 10 * 60_000
2284
2284
 
2285
+ // #1977 — single-tap correlation for the durable "🔁 Always allow"
2286
+ // flow. When the gateway dispatches a `config_propose_edit` to hostd in
2287
+ // response to an operator tap, hostd calls BACK asking for operator
2288
+ // approval. We pre-register the (agent, rule) pair here keyed
2289
+ // `${agentName}::${rule}` so that callback auto-approves WITHOUT a
2290
+ // second card. Forge-resistance: the auto-resolve match requires the
2291
+ // rule the inbound diff ADDS (via extractAddedAllowRule) to equal a
2292
+ // rule the gateway itself just queued — a forged edit touching any
2293
+ // other field finds no entry and falls through to a real operator card.
2294
+ // Single-shot (deleted on match) + 30s TTL sweep so a stale correlation
2295
+ // can't be replayed.
2296
+ const pendingAlwaysAllowCorrelations = new Map<string, { agentName: string; rule: string; unifiedDiff: string; createdAt: number }>()
2297
+ const ALWAYS_ALLOW_CORRELATION_TTL_MS = 30_000
2298
+ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
2299
+ for (const [key, entry] of pendingAlwaysAllowCorrelations) {
2300
+ if (now - entry.createdAt > ALWAYS_ALLOW_CORRELATION_TTL_MS) {
2301
+ pendingAlwaysAllowCorrelations.delete(key)
2302
+ }
2303
+ }
2304
+ }
2305
+
2285
2306
  // `ask_user` MCP tool — open prompts awaiting a user button-tap.
2286
2307
  // Keyed by askId (8 hex chars from generateAskId). Each entry holds
2287
2308
  // the deferred promise that resolves the originating tool call, the
@@ -3233,18 +3254,18 @@ const ANSWER_STREAM_VISIBLE_ENABLED = (() => {
3233
3254
  return true
3234
3255
  })()
3235
3256
 
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:
3257
+ // Activity-feed flag (RFC docs/rfcs/draft-mirror-preview.md). When enabled,
3258
+ // the gateway streams a live "what it's doing" tool-activity feed for the
3259
+ // turn. The PreToolUse sidecar emits a `tool_label` per tool call (flush-
3260
+ // independent, so it stays real-time on fast/clustered-tool turns); each
3261
+ // label appends to `turn.mirrorLines`, and `renderActivityFeed` renders the
3262
+ // capped list into an in-place EDITED message (sendMessage + editMessageText)
3263
+ // anchored as a native reply-quote to the user's question. The feed clears on
3264
+ // the first reply (hand-off to the answer) and again at turn_end (the no-reply
3265
+ // safety net). It does NOT touch the answer-stream's draft/visible lane the
3266
+ // two render on separate surfaces, so they never collide. (The env name is
3267
+ // historical: an earlier design mirrored into the compose-area draft; the feed
3268
+ // is now a normal edited message.) Default OFF (canary). Kill switch:
3248
3269
  // SWITCHROOM_DRAFT_MIRROR unset/0/false/off/no.
3249
3270
  const DRAFT_MIRROR_ENABLED = (() => {
3250
3271
  const raw = process.env.SWITCHROOM_DRAFT_MIRROR
@@ -4572,6 +4593,32 @@ const ipcServer: IpcServer = createIpcServer({
4572
4593
  },
4573
4594
  log: (m) =>
4574
4595
  process.stderr.write(`telegram gateway: config-approval — ${m}\n`),
4596
+ // #1977 single-tap correlation: auto-approve a config edit the
4597
+ // gateway itself just queued in response to an operator tap on
4598
+ // the "🔁 Always allow" permission card. Forge-resistant — the
4599
+ // match requires the rule the diff ADDS to equal a rule the
4600
+ // gateway queued; an agent-forged edit touching any other field
4601
+ // finds no entry and falls through to a real operator card.
4602
+ tryAutoResolve: (msg) => {
4603
+ sweepStaleAlwaysAllowCorrelations()
4604
+ // `extractAddedAllowRule` only locates a CANDIDATE entry by the
4605
+ // rule token — it is shape-based, not YAML-location-aware, so it
4606
+ // is NOT the security gate. The gate is an EXACT byte-match of
4607
+ // the incoming diff against the diff the gateway itself
4608
+ // synthesized and queued. A forged config_propose_edit (the same
4609
+ // consented token placed under `deny:`/`secrets:`, a different
4610
+ // field, or any other byte difference) won't match → falls
4611
+ // through to a real operator approval card.
4612
+ const added = extractAddedAllowRule(msg.unifiedDiff)
4613
+ if (!added) return null
4614
+ const key = `${msg.agentName}::${added}`
4615
+ const entry = pendingAlwaysAllowCorrelations.get(key)
4616
+ if (entry && entry.unifiedDiff === msg.unifiedDiff) {
4617
+ pendingAlwaysAllowCorrelations.delete(key)
4618
+ return 'approve'
4619
+ }
4620
+ return null
4621
+ },
4575
4622
  })
4576
4623
  },
4577
4624
 
@@ -6883,19 +6930,12 @@ function closeProgressLane(chatId: string, threadId: number | undefined): void {
6883
6930
  * `turn.activityInFlight`; while set, new tool_uses only update
6884
6931
  * `turn.activityPendingRender` and return).
6885
6932
  *
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.
6933
+ * Transport: a single in-place edited message. The first render does
6934
+ * `sendMessage` (capturing `turn.activityMessageId`); subsequent renders
6935
+ * `editMessageText` that id, so the summary accumulates in place without
6936
+ * retyping the whole block. `clearActivitySummary` deletes the message
6937
+ * when the reply tool takes over. Works in DMs, groups, and forum topics
6938
+ * alike (forum topics pass message_thread_id).
6899
6939
  *
6900
6940
  * The drain holds a reference to `turn`, so a turn-swap mid-drain
6901
6941
  * doesn't corrupt the next turn's atom — late writes land on the
@@ -6906,29 +6946,33 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
6906
6946
  while (turn.activityPendingRender !== turn.activityLastSentRender) {
6907
6947
  const target = turn.activityPendingRender
6908
6948
  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>`
6949
+ // Two mutually-exclusive producers feed `activityPendingRender`
6950
+ // (gated on DRAFT_MIRROR_ENABLED in handleSessionEvent):
6951
+ // - feed ON: `renderActivityFeed` already emitted ready Telegram HTML
6952
+ // with per-line markup (<b>→ current</b> / <i>✓ done</i>) and escaped
6953
+ // each label's <,>,& itself (#1942 class) — send verbatim, do NOT
6954
+ // re-escape or re-wrap (double-escaping would surface literal tags).
6955
+ // - feed OFF: the legacy verb-count summary is plain text — escape and
6956
+ // wrap in a single <i>.
6957
+ const html = DRAFT_MIRROR_ENABLED ? target : `<i>${escapeHtmlForTg(target)}</i>`
6915
6958
  const chat = turn.sessionChatId
6916
6959
  const thread = turn.sessionThreadId
6917
- // sendMessageDraft doesn't support forum threads.
6918
- const useDraft = turn.isDm && thread == null && sendMessageDraftFn != null
6960
+ // Native reply-quote: anchor the feed message to the user's question so
6961
+ // it renders as a quoted header (reply_parameters renders on a real
6962
+ // message; edits preserve it). Feed-only — the legacy summary is left
6963
+ // visually unchanged. allow_sending_without_reply so a deleted source
6964
+ // can't drop the send.
6965
+ const replyAnchor = DRAFT_MIRROR_ENABLED && turn.sourceMessageId != null
6966
+ ? { reply_parameters: { message_id: turn.sourceMessageId, allow_sending_without_reply: true } }
6967
+ : {}
6919
6968
  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) {
6969
+ if (turn.activityMessageId == null) {
6927
6970
  const sent = await robustApiCall(
6928
6971
  () => bot.api.sendMessage(chat, html, {
6929
6972
  ...(thread != null ? { message_thread_id: thread } : {}),
6930
6973
  parse_mode: 'HTML',
6931
6974
  disable_notification: true,
6975
+ ...replyAnchor,
6932
6976
  }),
6933
6977
  { chat_id: chat, ...(thread != null ? { threadId: thread } : {}), verb: 'activity-summary.send' },
6934
6978
  )
@@ -6958,30 +7002,20 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
6958
7002
  /**
6959
7003
  * Clear the activity summary when the model's reply tool takes over
6960
7004
  * 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.
7005
+ * don't race a stale write against the clear, then deletes the activity
7006
+ * message. Idempotent + best-effort — failure stderr-logs but does not
7007
+ * block.
6965
7008
  *
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.
7009
+ * Called on the first reply (hand-off to the answer) and again at
7010
+ * turn_end (the no-reply safety net), so the user sees the real reply
7011
+ * land in the same beat the summary disappears.
6969
7012
  */
6970
7013
  function clearActivitySummary(turn: CurrentTurn): void {
6971
7014
  const chat = turn.sessionChatId
6972
7015
  const thread = turn.sessionThreadId
6973
7016
  const inFlight = turn.activityInFlight ?? Promise.resolve()
6974
7017
  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) {
7018
+ if (turn.activityMessageId != null) {
6985
7019
  const id = turn.activityMessageId
6986
7020
  turn.activityMessageId = null
6987
7021
  try {
@@ -7040,6 +7074,9 @@ function handleSessionEvent(ev: SessionEvent): void {
7040
7074
  const next: CurrentTurn = {
7041
7075
  sessionChatId: ev.chatId,
7042
7076
  sessionThreadId: ev.threadId != null ? Number(ev.threadId) : undefined,
7077
+ sourceMessageId: ev.messageId != null && /^\d+$/.test(ev.messageId)
7078
+ ? Number(ev.messageId)
7079
+ : null,
7043
7080
  startedAt,
7044
7081
  gatewayReceiveAt: startedAt,
7045
7082
  replyCalled: false,
@@ -7055,7 +7092,6 @@ function handleSessionEvent(ev: SessionEvent): void {
7055
7092
  toolCallCount: 0,
7056
7093
  toolActivity: makeEmptyActivityState(),
7057
7094
  activityMessageId: null,
7058
- activityDraftId: null,
7059
7095
  activityInFlight: null,
7060
7096
  activityPendingRender: null,
7061
7097
  activityLastSentRender: null,
@@ -7192,21 +7228,14 @@ function handleSessionEvent(ev: SessionEvent): void {
7192
7228
  turn.orphanedReplyTimeoutId = null
7193
7229
  }
7194
7230
  // The model's real reply takes over as the authoritative
7195
- // surface. Clear the activity summary — for 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) {
7231
+ // surface, so delete the activity summary message the user
7232
+ // sees the real reply land in the same beat the summary
7233
+ // disappears. Applies to both producers (legacy verb-count and
7234
+ // the DRAFT_MIRROR feed); turn_end is the no-reply safety net.
7235
+ if (wasFirstReply) {
7205
7236
  clearActivitySummary(turn)
7206
7237
  }
7207
7238
  }
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
7239
  // Tool-activity summary — same shape Claude Code natively renders
7211
7240
  // in its CLI/chat UI ("Ran 5 commands, read a file"). The gateway
7212
7241
  // accumulates non-reply tool_use events into `turn.toolActivity`
@@ -7224,17 +7253,17 @@ function handleSessionEvent(ev: SessionEvent): void {
7224
7253
  // exactly once at a time and re-running until pending matches
7225
7254
  // the last-sent. Captures `turn` so a late drain after turn-swap
7226
7255
  // 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
7256
  //
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
7235
- // the determinism fix: on a fast/clustered-tool turn the JSONL
7236
- // tool_use rows aren't on disk until ~turn-end, so sourcing the
7237
- // draft here lost the feed; the sidecar is flush-independent.
7257
+ // This (flush-gated) tool_use path drives the summary ONLY when
7258
+ // DRAFT_MIRROR is OFF: the legacy generic verb-count summary
7259
+ // ("Ran 5 commands") via registerAndRender. When DRAFT_MIRROR is
7260
+ // ON the summary is instead driven by the real-time `tool_label`
7261
+ // event (PreToolUse sidecar, fires at tool-call time regardless of
7262
+ // when claude flushes the transcript) see `case 'tool_label'`.
7263
+ // That's the determinism fix: on a fast/clustered-tool turn the
7264
+ // JSONL tool_use rows aren't on disk until ~turn-end, so sourcing
7265
+ // the feed here lost it; the sidecar is flush-independent. Both
7266
+ // producers feed `activityPendingRender` and clear on first reply.
7238
7267
  if (!DRAFT_MIRROR_ENABLED && !turn.replyCalled && !isTelegramSurfaceTool(name)) {
7239
7268
  const rendered = registerAndRender(turn.toolActivity, name)
7240
7269
  if (rendered != null) {
@@ -7256,18 +7285,24 @@ function handleSessionEvent(ev: SessionEvent): void {
7256
7285
  // DRAFT_MIRROR real-time driver. The PreToolUse hook wrote this
7257
7286
  // label synchronously at tool-call time; the sidecar surfaced it
7258
7287
  // 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.
7288
+ // into the live feed and edit the activity message in place — this
7289
+ // is what makes the feed deterministic on fast/clustered-tool turns
7290
+ // where the JSONL tool_use rows arrive too late.
7262
7291
  if (!DRAFT_MIRROR_ENABLED) return
7263
7292
  const turn = currentTurn
7264
7293
  if (turn == null) return
7265
7294
  // Surface tools (reply/stream_reply/react) are the conversation, not
7266
7295
  // activity — the hook labels them ("Replying"), so filter by name.
7267
7296
  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.
7297
+ // Stop feeding once the reply has landed. The first reply is the
7298
+ // hand-off: `clearActivitySummary` deletes the feed so the answer is
7299
+ // the authoritative surface (the validated clean hand-off). Without
7300
+ // this gate a tool called after the reply would re-`sendMessage` a
7301
+ // fresh feed message below the answer — a delete-then-resend flicker.
7302
+ // Safe ordering: `tool_label` is real-time (PreToolUse, ~250ms) while
7303
+ // `replyCalled` is set from the lagged reply tool_use, so a genuinely
7304
+ // pre-reply label virtually always arrives before the flag flips.
7305
+ if (turn.replyCalled) return
7271
7306
  const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
7272
7307
  if (rendered != null) {
7273
7308
  turn.activityPendingRender = rendered
@@ -7547,11 +7582,12 @@ function handleSessionEvent(ev: SessionEvent): void {
7547
7582
  clearTimeout(turn.orphanedReplyTimeoutId)
7548
7583
  turn.orphanedReplyTimeoutId = null
7549
7584
  }
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.
7585
+ // DRAFT_MIRROR: clear the activity feed at the real end of the turn.
7586
+ // This is the no-reply safety net a turn that ends without ever
7587
+ // calling reply (the answer is delivered by turn-flush / silent-end)
7588
+ // still has its feed removed. On a normal turn the feed was already
7589
+ // cleared at the first reply (the hand-off); clearActivitySummary is
7590
+ // idempotent, so the second call is a no-op.
7555
7591
  if (DRAFT_MIRROR_ENABLED && turn != null) {
7556
7592
  clearActivitySummary(turn)
7557
7593
  }
@@ -15266,13 +15302,20 @@ bot.on('callback_query:data', async ctx => {
15266
15302
  }
15267
15303
 
15268
15304
  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.
15305
+ // "🔁 Always allow" (#1977) persist the resolved rule into the
15306
+ // agent's tools.allow in the DURABLE host config. The old path
15307
+ // shelled `switchroom agent grant` which wrote
15308
+ // /state/config/switchroom.yaml but that path is bind-mounted
15309
+ // READ-ONLY into agent containers, so the write silently no-op'd.
15310
+ // Durable host-config writes only land via the host-side hostd
15311
+ // daemon's `config_propose_edit` flow. We:
15312
+ // 1. dispatch the in-flight Allow verdict IMMEDIATELY (turn must
15313
+ // not block on the host round-trip);
15314
+ // 2. if hostd is not-configured → fall back to the legacy
15315
+ // `agent grant` + verify path (honest messaging only);
15316
+ // 3. otherwise synthesize a unified diff adding the rule to the
15317
+ // agent's tools.allow and send it to hostd, awaiting the
15318
+ // apply+reconcile result.
15276
15319
  const details = pendingPermissions.get(request_id)
15277
15320
  if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
15278
15321
  const rule = resolveAlwaysAllowRule(details.tool_name, details.input_preview)
@@ -15285,57 +15328,143 @@ bot.on('callback_query:data', async ctx => {
15285
15328
  await ctx.answerCallbackQuery({ text: 'Always-allow needs SWITCHROOM_AGENT_NAME — gateway is misconfigured.' }).catch(() => {})
15286
15329
  return
15287
15330
  }
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.
15331
+
15332
+ pendingPermissions.delete(request_id)
15333
+
15334
+ // (2) Dispatch the in-flight permission verdict IMMEDIATELY — before
15335
+ // any host round-trip so the turn never blocks on persistence.
15336
+ // We carry the resolved `rule` so the bridge caches it for the rest
15337
+ // of the session and auto-allows matching tool calls from sub-agents
15338
+ // (Task tool) + the parent without re-popping the prompt (#1138).
15339
+ // The rule is safe to cache regardless of whether the *durable*
15340
+ // write later succeeds it's the operator's explicit intent.
15341
+ dispatchPermissionVerdict({
15342
+ type: 'permission',
15343
+ requestId: request_id,
15344
+ behavior: 'allow',
15345
+ rule: rule.rule,
15346
+ })
15347
+
15348
+ // (3) Decide the persistence path. tryHostdDispatch returns
15349
+ // "not-configured" when host_control is disabled or the per-agent
15350
+ // socket is absent → legacy fallback.
15351
+ let durable = false
15352
+ let legacy = false
15353
+ let failReason = ''
15354
+ let editLockHint = false
15355
+
15356
+ const configEditDisabled = (msg: string): boolean =>
15357
+ msg.includes('E_CONFIG_EDIT_DISABLED')
15358
+
15359
+ const unifiedDiff = (() => {
15300
15360
  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
- }
15361
+ const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findSwitchroomConfigFile()
15362
+ const raw = readFileSync(cfgPath, 'utf8')
15363
+ return synthesizeAllowRuleDiff({ agentName, rule: rule.rule, configText: raw })
15364
+ } catch (err) {
15365
+ process.stderr.write(`telegram gateway: always-allow diff synth failed: ${(err as Error).message}\n`)
15366
+ return null
15367
+ }
15368
+ })()
15369
+
15370
+ const correlationKey = `${agentName}::${rule.rule}`
15371
+ try {
15372
+ if (unifiedDiff == null) {
15373
+ // Could not locate the agent block / read config → fall back to
15374
+ // the legacy grant path; its own verify will produce honest
15375
+ // messaging.
15376
+ legacy = true
15377
+ } else {
15378
+ // Pre-register the single-tap correlation so hostd's callback
15379
+ // (request_config_approval) auto-approves WITHOUT a second card.
15380
+ pendingAlwaysAllowCorrelations.set(correlationKey, { agentName, rule: rule.rule, unifiedDiff, createdAt: Date.now() })
15381
+ const req: HostdRequest = {
15382
+ v: 1,
15383
+ op: 'config_propose_edit',
15384
+ request_id: hostdRequestId('gw-always-allow'),
15385
+ args: {
15386
+ unified_diff: unifiedDiff,
15387
+ reason: `Operator 'always allow' for ${rule.label}`,
15388
+ target_path: '/state/config/switchroom.yaml',
15389
+ },
15390
+ }
15391
+ // config_propose_edit blocks on validate→approve→apply→reconcile,
15392
+ // so allow ~60s (well past the default 5s).
15393
+ const resp = await tryHostdDispatch(agentName, req, 60_000)
15394
+ if (resp === 'not-configured') {
15395
+ warnLegacySpawnIfHostdDisabled('always-allow')
15396
+ legacy = true
15397
+ } else if (resp.result === 'completed') {
15398
+ durable = true
15399
+ process.stderr.write(
15400
+ `telegram gateway: always-allow durable via hostd rule="${rule.rule}" agent=${agentName} (request_id=${request_id})\n`,
15401
+ )
15317
15402
  } else {
15318
- grantFailReason = `agent "${agentName}" not found in config after write`
15403
+ failReason = resp.error ?? `hostd ${resp.result}`
15404
+ if (configEditDisabled(failReason)) editLockHint = true
15319
15405
  process.stderr.write(
15320
- `telegram gateway: always-allow VERIFY FAILED: ${grantFailReason} (request_id=${request_id})\n`,
15406
+ `telegram gateway: always-allow hostd FAILED: ${failReason} (request_id=${request_id})\n`,
15321
15407
  )
15322
15408
  }
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
15409
  }
15329
- } catch (err) {
15330
- grantFailReason = (err as Error).message
15331
- process.stderr.write(`telegram gateway: always-allow grant failed: ${grantFailReason}\n`)
15332
- }
15333
15410
 
15334
- pendingPermissions.delete(request_id)
15411
+ if (legacy) {
15412
+ // Legacy not-configured fallback — keep TODAY's behaviour:
15413
+ // shell `agent grant` (writes the host yaml only when the
15414
+ // gateway has a writable path) + verify the rule actually
15415
+ // landed. Honest messaging: "saved (legacy path)" on verify,
15416
+ // else the "did NOT save" warning.
15417
+ try {
15418
+ switchroomExec(['agent', 'grant', agentName, rule.rule, '--no-restart'])
15419
+ try {
15420
+ const cfg = loadSwitchroomConfig()
15421
+ const rawAgent = cfg.agents?.[agentName]
15422
+ if (rawAgent) {
15423
+ const resolved = resolveAgentConfig(cfg.defaults, cfg.profiles, rawAgent)
15424
+ const allowList: string[] = (resolved as { tools?: { allow?: string[] } }).tools?.allow ?? []
15425
+ if (isRulePersisted(allowList, rule.rule)) {
15426
+ durable = true // legacy path verified — durable on this host shape
15427
+ process.stderr.write(
15428
+ `telegram gateway: always-allow added rule="${rule.rule}" agent=${agentName} via legacy grant (request_id=${request_id})\n`,
15429
+ )
15430
+ } else {
15431
+ failReason = `rule "${rule.rule}" not found in resolved tools.allow after write — config location may have drifted`
15432
+ process.stderr.write(
15433
+ `telegram gateway: always-allow VERIFY FAILED: ${failReason} (request_id=${request_id})\n`,
15434
+ )
15435
+ }
15436
+ } else {
15437
+ failReason = `agent "${agentName}" not found in config after write`
15438
+ process.stderr.write(
15439
+ `telegram gateway: always-allow VERIFY FAILED: ${failReason} (request_id=${request_id})\n`,
15440
+ )
15441
+ }
15442
+ } catch (verifyErr) {
15443
+ failReason = `config re-read failed: ${(verifyErr as Error).message}`
15444
+ process.stderr.write(
15445
+ `telegram gateway: always-allow VERIFY FAILED: ${failReason} (request_id=${request_id})\n`,
15446
+ )
15447
+ }
15448
+ } catch (err) {
15449
+ failReason = (err as Error).message
15450
+ process.stderr.write(`telegram gateway: always-allow grant failed: ${failReason}\n`)
15451
+ }
15452
+ }
15453
+ } finally {
15454
+ // Single-shot correlation — drop it whether or not it was
15455
+ // consumed by hostd's callback, so it can never be replayed.
15456
+ pendingAlwaysAllowCorrelations.delete(correlationKey)
15457
+ }
15335
15458
 
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.`
15459
+ const ok = durable
15460
+ const legacyNote = legacy && durable
15461
+ const ackText = ok
15462
+ ? (legacyNote
15463
+ ? `🔁 Always allow ${rule.label} for ${agentName} (legacy path)`
15464
+ : `🔁 Always allow ${rule.label} for ${agentName}`)
15465
+ : (editLockHint
15466
+ ? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
15467
+ : `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
15339
15468
  // HTML-escape baseText — `ctx.callbackQuery.message.text` returns
15340
15469
  // entities-stripped plain UTF-8, so raw `<`/`>`/`&` in the
15341
15470
  // expanded permission card's `description` or `input_preview`
@@ -15346,37 +15475,22 @@ bot.on('callback_query:data', async ctx => {
15346
15475
  const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
15347
15476
  ? escapeHtmlForTg(sourceMsg.text)
15348
15477
  : ''
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.`
15478
+ const editLabel = ok
15479
+ ? (legacyNote
15480
+ ? `🔁 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName)} saved (legacy path); restart agent for full effect`
15481
+ : `🔁 <b>Always allow ${escapeHtmlForTg(rule.label)}</b> for ${escapeHtmlForTg(agentName)} — saved; restart agent for full effect`)
15482
+ : (editLockHint
15483
+ ? `⚠️ <b>Allowed for now — "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.`
15484
+ : `⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`)
15352
15485
  // #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.
15486
+ // strips alongside the status-line edit. The in-flight verdict was
15487
+ // ALREADY dispatched above (independently of this host round-trip)
15488
+ // so the turn never blockedfinalizeCallback here only edits the
15489
+ // card; no synthInbound (would double-fire the verdict).
15357
15490
  await finalizeCallback(ctx, {
15358
15491
  ackText: ackText.slice(0, 200),
15359
15492
  newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
15360
15493
  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
15494
  })
15381
15495
  return
15382
15496
  }
@@ -15418,7 +15532,9 @@ bot.on('callback_query:data', async ctx => {
15418
15532
  })
15419
15533
 
15420
15534
  // ─── Inbound message handlers ─────────────────────────────────────────────
15421
- bot.on('message:text', async ctx => { await handleInboundCoalesced(ctx, ctx.message.text, undefined) })
15535
+ bot.on('message:text', async ctx => {
15536
+ await handleInboundCoalesced(ctx, ctx.message.text, undefined)
15537
+ })
15422
15538
 
15423
15539
  bot.on('message:photo', async ctx => {
15424
15540
  const caption = ctx.message.caption ?? '(photo)'