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.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +396 -358
- package/dist/host-control/main.js +148 -148
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/examples/switchroom.yaml +1 -1
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +23 -0
- package/skills/switchroom-status/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +583 -284
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/config-approval-handler.ts +36 -0
- package/telegram-plugin/gateway/gateway.ts +296 -180
- package/telegram-plugin/gateway/hostd-dispatch.ts +2 -1
- package/telegram-plugin/permission-diff.ts +382 -0
- package/telegram-plugin/tests/always-allow-correlation.test.ts +147 -0
- package/telegram-plugin/tests/always-allow-grant.test.ts +84 -88
- package/telegram-plugin/tests/permission-diff.test.ts +336 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +25 -13
- package/telegram-plugin/tool-activity-summary.ts +27 -15
|
@@ -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
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
//
|
|
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
|
-
//
|
|
3237
|
-
//
|
|
3238
|
-
//
|
|
3239
|
-
//
|
|
3240
|
-
//
|
|
3241
|
-
//
|
|
3242
|
-
//
|
|
3243
|
-
//
|
|
3244
|
-
//
|
|
3245
|
-
//
|
|
3246
|
-
//
|
|
3247
|
-
//
|
|
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
|
|
6887
|
-
*
|
|
6888
|
-
*
|
|
6889
|
-
*
|
|
6890
|
-
*
|
|
6891
|
-
*
|
|
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
|
-
//
|
|
6910
|
-
//
|
|
6911
|
-
//
|
|
6912
|
-
//
|
|
6913
|
-
//
|
|
6914
|
-
|
|
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
|
-
//
|
|
6918
|
-
|
|
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 (
|
|
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
|
|
6962
|
-
*
|
|
6963
|
-
*
|
|
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
|
|
6967
|
-
*
|
|
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.
|
|
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
|
|
7196
|
-
//
|
|
7197
|
-
//
|
|
7198
|
-
//
|
|
7199
|
-
|
|
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
|
-
//
|
|
7232
|
-
//
|
|
7233
|
-
// (
|
|
7234
|
-
//
|
|
7235
|
-
//
|
|
7236
|
-
//
|
|
7237
|
-
//
|
|
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
|
|
7260
|
-
// makes the
|
|
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
|
-
//
|
|
7269
|
-
//
|
|
7270
|
-
// the
|
|
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
|
|
7551
|
-
//
|
|
7552
|
-
//
|
|
7553
|
-
//
|
|
7554
|
-
//
|
|
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" —
|
|
15270
|
-
// tools.allow in
|
|
15271
|
-
//
|
|
15272
|
-
//
|
|
15273
|
-
//
|
|
15274
|
-
//
|
|
15275
|
-
//
|
|
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
|
-
|
|
15289
|
-
|
|
15290
|
-
|
|
15291
|
-
|
|
15292
|
-
|
|
15293
|
-
|
|
15294
|
-
|
|
15295
|
-
|
|
15296
|
-
|
|
15297
|
-
|
|
15298
|
-
|
|
15299
|
-
|
|
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
|
|
15302
|
-
const
|
|
15303
|
-
|
|
15304
|
-
|
|
15305
|
-
|
|
15306
|
-
|
|
15307
|
-
|
|
15308
|
-
|
|
15309
|
-
|
|
15310
|
-
|
|
15311
|
-
|
|
15312
|
-
|
|
15313
|
-
|
|
15314
|
-
|
|
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
|
-
|
|
15403
|
+
failReason = resp.error ?? `hostd ${resp.result}`
|
|
15404
|
+
if (configEditDisabled(failReason)) editLockHint = true
|
|
15319
15405
|
process.stderr.write(
|
|
15320
|
-
`telegram gateway: always-allow
|
|
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
|
-
|
|
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
|
|
15337
|
-
|
|
15338
|
-
|
|
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 =
|
|
15350
|
-
?
|
|
15351
|
-
|
|
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.
|
|
15354
|
-
//
|
|
15355
|
-
//
|
|
15356
|
-
// fire the
|
|
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 blocked — finalizeCallback 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 => {
|
|
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)'
|