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.
- package/dist/cli/switchroom.js +40 -2
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +23 -0
- package/telegram-plugin/dist/gateway/gateway.js +397 -225
- package/telegram-plugin/gateway/config-approval-handler.ts +36 -0
- package/telegram-plugin/gateway/gateway.ts +285 -225
- 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 -229
- package/telegram-plugin/tool-activity-summary.ts +45 -212
|
@@ -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 {
|
|
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
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
// `
|
|
1364
|
-
// in
|
|
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
|
-
//
|
|
3237
|
-
//
|
|
3238
|
-
//
|
|
3239
|
-
//
|
|
3240
|
-
//
|
|
3241
|
-
//
|
|
3242
|
-
//
|
|
3243
|
-
//
|
|
3244
|
-
// draft
|
|
3245
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
6910
|
-
//
|
|
6911
|
-
//
|
|
6912
|
-
//
|
|
6913
|
-
|
|
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
|
-
//
|
|
6918
|
-
|
|
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 (
|
|
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
|
|
6962
|
-
*
|
|
6963
|
-
*
|
|
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
|
|
6967
|
-
*
|
|
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.
|
|
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
|
|
7196
|
-
//
|
|
7197
|
-
//
|
|
7198
|
-
|
|
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
|
-
//
|
|
7209
|
-
// (
|
|
7210
|
-
//
|
|
7211
|
-
//
|
|
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
|
+
// path — see `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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
7260
|
-
// makes the
|
|
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
|
-
//
|
|
7269
|
-
//
|
|
7270
|
-
// the
|
|
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
|
-
//
|
|
7551
|
-
//
|
|
7552
|
-
//
|
|
7553
|
-
//
|
|
7554
|
-
//
|
|
7555
|
-
|
|
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" —
|
|
15270
|
-
// tools.allow in
|
|
15271
|
-
//
|
|
15272
|
-
//
|
|
15273
|
-
//
|
|
15274
|
-
//
|
|
15275
|
-
//
|
|
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
|
-
|
|
15289
|
-
|
|
15290
|
-
|
|
15291
|
-
|
|
15292
|
-
|
|
15293
|
-
|
|
15294
|
-
|
|
15295
|
-
|
|
15296
|
-
|
|
15297
|
-
|
|
15298
|
-
|
|
15299
|
-
|
|
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
|
|
15302
|
-
const
|
|
15303
|
-
|
|
15304
|
-
|
|
15305
|
-
|
|
15306
|
-
|
|
15307
|
-
|
|
15308
|
-
|
|
15309
|
-
|
|
15310
|
-
|
|
15311
|
-
|
|
15312
|
-
|
|
15313
|
-
|
|
15314
|
-
|
|
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
|
-
|
|
15347
|
+
failReason = resp.error ?? `hostd ${resp.result}`
|
|
15348
|
+
if (configEditDisabled(failReason)) editLockHint = true
|
|
15319
15349
|
process.stderr.write(
|
|
15320
|
-
`telegram gateway: always-allow
|
|
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
|
-
|
|
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
|
|
15337
|
-
|
|
15338
|
-
|
|
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 =
|
|
15350
|
-
?
|
|
15351
|
-
|
|
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.
|
|
15354
|
-
//
|
|
15355
|
-
//
|
|
15356
|
-
// fire the
|
|
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 blocked — finalizeCallback 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 => {
|
|
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)'
|