switchroom 0.15.17 → 0.15.19

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.
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Telegram `/effort` command — show or switch the Claude reasoning effort
3
+ * for this agent's live session. The effort sibling of `/model`.
4
+ *
5
+ * `/effort` (bare) renders the effort menu: the configured default plus an
6
+ * inline keyboard of the five levels the CLI offers
7
+ * (`low · medium · high · xhigh · max`, faster→smarter), the live level
8
+ * marked ✅. A tap types claude's own `/effort <level>` into the agent's
9
+ * tmux pane via the allowlisted inject primitive (`/effort` is on the
10
+ * inject allowlist) — the Claude-native mechanism: the unmodified CLI's
11
+ * REPL command, no API, no SDK, no config mutation.
12
+ *
13
+ * `/effort <level>` does the same non-interactively.
14
+ *
15
+ * The switch is session-scoped. It lasts until the agent restarts, because
16
+ * `start.sh` always relaunches claude with `--effort <thinking_effort>`
17
+ * (the cascade-resolved default, "low" out of the box) which re-pins the
18
+ * session effort on boot. Persisting a new default is a `thinking_effort:`
19
+ * change in switchroom.yaml + restart, which the reply spells out.
20
+ *
21
+ * Split parser/handler shape mirrors `model-command.ts` so the logic is
22
+ * unit-testable without booting the bot.
23
+ */
24
+
25
+ import type { InjectResult } from '../../src/agents/inject.js'
26
+
27
+ /**
28
+ * The effort levels the installed CLI accepts (`claude --help`:
29
+ * "--effort <level> … (low, medium, high, xhigh, max)"). Fixed and
30
+ * ordered faster→smarter. Unlike model ids these don't churn, so they're
31
+ * listed here rather than discovered live — a new level needs a one-line
32
+ * edit, surfaced by the regression test.
33
+ */
34
+ export const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'] as const
35
+ export type EffortLevel = (typeof EFFORT_LEVELS)[number]
36
+
37
+ /** Strict allowlist gate — the arg is typed verbatim into the agent pane. */
38
+ export function isValidEffortArg(arg: string): boolean {
39
+ return (EFFORT_LEVELS as readonly string[]).includes(arg.toLowerCase())
40
+ }
41
+
42
+ export type ParsedEffortCommand =
43
+ | { kind: 'show' }
44
+ | { kind: 'set'; level: EffortLevel }
45
+ | { kind: 'help'; reason?: string }
46
+
47
+ /**
48
+ * Parse an `/effort` message. Returns null when the text isn't an /effort
49
+ * command at all (caller bug — bot.command should pre-filter).
50
+ */
51
+ export function parseEffortCommand(text: string): ParsedEffortCommand | null {
52
+ const m = text.match(/^\/effort(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/)
53
+ if (!m) return null
54
+ const rest = (m[1] ?? '').trim()
55
+ if (rest.length === 0) return { kind: 'show' }
56
+ const parts = rest.split(/\s+/)
57
+ if (parts.length > 1) {
58
+ return { kind: 'help', reason: 'effort takes a single level' }
59
+ }
60
+ const arg = parts[0]
61
+ if (arg.toLowerCase() === 'help') return { kind: 'help' }
62
+ if (!isValidEffortArg(arg)) {
63
+ return { kind: 'help', reason: `not a valid effort level: ${arg}` }
64
+ }
65
+ return { kind: 'set', level: arg.toLowerCase() as EffortLevel }
66
+ }
67
+
68
+ export interface EffortCommandDeps {
69
+ /** Inject primitive — wired to injectSlashCommand in the gateway. */
70
+ inject: (agent: string, command: string) => Promise<InjectResult>
71
+ getAgentName: () => string
72
+ /**
73
+ * The agent's cascade-resolved `thinking_effort` from
74
+ * `switchroom agent list` (the value start.sh bakes into `--effort`).
75
+ * Null when unreadable — rendered as the built-in default.
76
+ */
77
+ getConfiguredEffort: () => string | null
78
+ escapeHtml: (s: string) => string
79
+ preBlock: (s: string) => string
80
+ }
81
+
82
+ export interface EffortCommandReply {
83
+ text: string
84
+ html: true
85
+ }
86
+
87
+ const PERSIST_NOTE =
88
+ '<i>Session-only — reverts to the configured default on restart. To change the default, set <code>thinking_effort:</code> in switchroom.yaml and restart.</i>'
89
+
90
+ const LEVELS_INLINE = EFFORT_LEVELS.map(l => `<code>${l}</code>`).join(' · ')
91
+
92
+ function helpText(deps: EffortCommandDeps, reason?: string): EffortCommandReply {
93
+ const lines: string[] = []
94
+ if (reason) lines.push(`⚠️ ${deps.escapeHtml(reason)}`)
95
+ lines.push(
96
+ '<b>/effort</b> — show or switch the reasoning effort (faster→smarter)',
97
+ '<code>/effort</code> — show the configured effort + a tap menu',
98
+ `<code>/effort &lt;level&gt;</code> — switch the live session (${LEVELS_INLINE})`,
99
+ PERSIST_NOTE,
100
+ )
101
+ return { text: lines.join('\n'), html: true }
102
+ }
103
+
104
+ export async function handleEffortCommand(
105
+ parsed: ParsedEffortCommand,
106
+ deps: EffortCommandDeps,
107
+ ): Promise<EffortCommandReply> {
108
+ if (parsed.kind === 'help') return helpText(deps, parsed.reason)
109
+
110
+ if (parsed.kind === 'show') {
111
+ const configured = deps.getConfiguredEffort()
112
+ const shown = configured && configured.length > 0 ? configured : 'low'
113
+ return {
114
+ text: [
115
+ `<b>Effort — ${deps.escapeHtml(deps.getAgentName())}</b>`,
116
+ `Configured default: <code>${deps.escapeHtml(shown)}</code>`,
117
+ `Switch the live session: ${EFFORT_LEVELS.map(l => `<code>/effort ${l}</code>`).join(' · ')}`,
118
+ PERSIST_NOTE,
119
+ ].join('\n'),
120
+ html: true,
121
+ }
122
+ }
123
+
124
+ // kind === 'set' — re-gate at the seam so a caller that skipped the
125
+ // parser can't type arbitrary keys into the pane.
126
+ if (!isValidEffortArg(parsed.level)) {
127
+ return helpText(deps, `not a valid effort level: ${parsed.level}`)
128
+ }
129
+ const verbHtml = `<code>/effort ${deps.escapeHtml(parsed.level)}</code>`
130
+ let result: InjectResult
131
+ try {
132
+ result = await deps.inject(deps.getAgentName(), `/effort ${parsed.level}`)
133
+ } catch (err) {
134
+ const msg = err instanceof Error ? err.message : String(err)
135
+ return { text: `❌ ${verbHtml} — inject failed: ${deps.escapeHtml(msg)}`, html: true }
136
+ }
137
+
138
+ if (result.outcome === 'ok') {
139
+ return {
140
+ text: [
141
+ `${verbHtml}`,
142
+ deps.preBlock(result.output),
143
+ ...(result.truncated ? ['<i>truncated</i>'] : []),
144
+ PERSIST_NOTE,
145
+ ].join('\n'),
146
+ html: true,
147
+ }
148
+ }
149
+ if (result.outcome === 'ok_no_output') {
150
+ return {
151
+ text: [
152
+ `${verbHtml} — sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active effort.`,
153
+ PERSIST_NOTE,
154
+ ].join('\n'),
155
+ html: true,
156
+ }
157
+ }
158
+ // outcome === 'failed'
159
+ if (result.errorCode === 'session_missing') {
160
+ return {
161
+ text:
162
+ '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.',
163
+ html: true,
164
+ }
165
+ }
166
+ return {
167
+ text: `❌ ${verbHtml} — ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`,
168
+ html: true,
169
+ }
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Button menu — five fixed levels, the live one marked ✅. No live discovery
174
+ // (the levels don't churn) and no picker-driving (the inline `/effort <level>`
175
+ // form sets it directly), so this is far simpler than the /model menu.
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export interface EffortMenuKeyboardButton {
179
+ text: string
180
+ callback_data: string
181
+ }
182
+
183
+ export interface EffortMenuReply {
184
+ text: string
185
+ html: true
186
+ keyboard?: EffortMenuKeyboardButton[][]
187
+ }
188
+
189
+ export const EFFORT_CALLBACK_PREFIX = 'eff:'
190
+ const EFFORT_CALLBACK_SELECT = 'eff:s:'
191
+
192
+ export function effortSelectCallbackData(level: string): string {
193
+ return `${EFFORT_CALLBACK_SELECT}${level}`
194
+ }
195
+
196
+ function menuKeyboard(highlight: string): EffortMenuKeyboardButton[][] {
197
+ // Five short labels fit one row (Telegram allows up to 8/row). ✅ marks
198
+ // the level we believe is live.
199
+ return [
200
+ EFFORT_LEVELS.map(l => ({
201
+ text: l === highlight ? `✅ ${l}` : l,
202
+ callback_data: effortSelectCallbackData(l),
203
+ })),
204
+ ]
205
+ }
206
+
207
+ /**
208
+ * Build the `/effort` menu: configured default + a tap row of the five
209
+ * levels. `highlight` marks the level shown as live (defaults to the
210
+ * configured value; the callback path passes the just-selected level).
211
+ */
212
+ export function buildEffortMenu(deps: EffortCommandDeps, highlight?: string): EffortMenuReply {
213
+ const configured = deps.getConfiguredEffort() || 'low'
214
+ const live = highlight ?? configured
215
+ return {
216
+ text: [
217
+ `<b>Effort — ${deps.escapeHtml(deps.getAgentName())}</b>`,
218
+ `Default: <code>${deps.escapeHtml(configured)}</code> · faster → smarter: ${LEVELS_INLINE}`,
219
+ 'Tap to switch the live session:',
220
+ PERSIST_NOTE,
221
+ ].join('\n'),
222
+ html: true,
223
+ keyboard: menuKeyboard(live),
224
+ }
225
+ }
226
+
227
+ export interface EffortCallbackOutcome {
228
+ reply: EffortMenuReply
229
+ /** The level applied to the live session, if any (gateway records it). */
230
+ selectedEffort?: string
231
+ }
232
+
233
+ /**
234
+ * Handle an `eff:*` callback tap. `eff:s:<level>` injects claude's
235
+ * `/effort <level>` and re-renders the menu with a one-line banner and the
236
+ * new level checked. Never throws — failures render as a banner.
237
+ */
238
+ export async function handleEffortMenuCallback(
239
+ data: string,
240
+ deps: EffortCommandDeps,
241
+ ): Promise<EffortCallbackOutcome> {
242
+ if (!data.startsWith(EFFORT_CALLBACK_SELECT)) {
243
+ return { reply: buildEffortMenu(deps) }
244
+ }
245
+ const level = data.slice(EFFORT_CALLBACK_SELECT.length)
246
+ if (!isValidEffortArg(level)) {
247
+ return { reply: buildEffortMenu(deps) }
248
+ }
249
+ let banner: string
250
+ let selected: string | undefined
251
+ try {
252
+ const result = await deps.inject(deps.getAgentName(), `/effort ${level}`)
253
+ if (result.outcome === 'ok' || result.outcome === 'ok_no_output') {
254
+ banner = `✅ Effort → <code>${deps.escapeHtml(level)}</code> for this session`
255
+ selected = level
256
+ } else if (result.errorCode === 'session_missing') {
257
+ banner = '❌ tmux session not found — is the agent running under the supervisor?'
258
+ } else {
259
+ banner = `❌ couldn't set effort: ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`
260
+ }
261
+ } catch (err) {
262
+ const msg = err instanceof Error ? err.message : String(err)
263
+ banner = `❌ inject failed: ${deps.escapeHtml(msg)}`
264
+ }
265
+ // Re-render with the just-selected level checked (or the configured
266
+ // default if the inject failed) and the banner on top.
267
+ const menu = buildEffortMenu(deps, selected)
268
+ return {
269
+ reply: { ...menu, text: `${banner}\n${menu.text}` },
270
+ selectedEffort: selected,
271
+ }
272
+ }
@@ -271,6 +271,15 @@ import {
271
271
  type ModelMenuReply,
272
272
  } from './model-command.js'
273
273
  import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
274
+ import {
275
+ parseEffortCommand,
276
+ handleEffortCommand,
277
+ buildEffortMenu,
278
+ handleEffortMenuCallback,
279
+ EFFORT_CALLBACK_PREFIX,
280
+ type EffortCommandDeps,
281
+ type EffortMenuReply,
282
+ } from './effort-command.js'
274
283
  import { type BannerState } from '../slot-banner.js'
275
284
  import { refreshBanner } from '../slot-banner-driver.js'
276
285
  import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
@@ -3660,8 +3669,9 @@ function sweepStaleAlwaysAllowCorrelations(now = Date.now()): void {
3660
3669
  }
3661
3670
  }
3662
3671
 
3663
- // "⏱ 30 min" scoped-approval store (the middle tier between Allow-once and
3664
- // 🔁 Always). Operator-tapped, gateway-side ONLY (never pushed to the
3672
+ // Scoped-approval store: the 30-min window that backs the "✅ Allow" tap for
3673
+ // narrow non-destructive scopes (not a separate button — it IS what Allow
3674
+ // means for those). Operator-tapped, gateway-side ONLY (never pushed to the
3665
3675
  // bridge's untimed sessionAllowRules), fixed-window, fail-closed. Keyed by
3666
3676
  // agent name for per-agent isolation. All policy lives in
3667
3677
  // ../scoped-approval.ts (pure + unit-tested); this gateway only wires it.
@@ -5605,24 +5615,27 @@ const pendingPermissionBuffer = createPendingPermissionBuffer()
5605
5615
  * are resolved at call-time, after module init.)
5606
5616
  */
5607
5617
  /**
5608
- * The default permission-card action row: ❌ Deny · ✅ Allow once ·
5618
+ * The default permission-card action row: ❌ Deny · ✅ Allow ·
5609
5619
  * 🔁 Always… (the last only when a meaningful always-rule exists).
5610
5620
  * Tapping "🔁 Always…" swaps this row for the scope sub-menu; "← Back"
5611
5621
  * rebuilds this row. callback_data stays tiny (verb + 5-char id) so we
5612
5622
  * never approach Telegram's 64-byte ceiling.
5623
+ *
5624
+ * "✅ Allow" is no longer always literally "once": for a NARROW,
5625
+ * non-destructive scope it auto-grants a fixed 30-min window (so the same
5626
+ * action stops re-asking) — that is the default behavior of the allow tap,
5627
+ * not a separate button. Broad / MCP / destructive scopes stay truly once.
5628
+ * The post-tap card states which happened (honest-card contract). The
5629
+ * decision lives in the allow handler via resolveTimeBox; the label here is
5630
+ * deliberately neutral ("Allow", not "Allow once" or "Allow 30 min").
5613
5631
  */
5614
5632
  function buildPermissionActionRow(
5615
5633
  requestId: string,
5616
5634
  showAlways: boolean,
5617
- showTimeBox = false,
5618
5635
  ): InlineKeyboard {
5619
5636
  const kb = new InlineKeyboard()
5620
5637
  .text('❌ Deny', `perm:deny:${requestId}`)
5621
- .text('✅ Allow once', `perm:allow:${requestId}`)
5622
- // "⏱ 30 min" sits between once and always. Only shown for a narrow,
5623
- // non-destructive scope (resolveTimeBox decides); broad/MCP/destructive
5624
- // requests get once/always only.
5625
- if (showTimeBox) kb.text('⏱ 30 min', `perm:tmb:${requestId}`)
5638
+ .text('✅ Allow', `perm:allow:${requestId}`)
5626
5639
  if (showAlways) kb.text('🔁 Always…', `perm:always:${requestId}`)
5627
5640
  return kb
5628
5641
  }
@@ -6044,19 +6057,15 @@ const ipcServer: IpcServer = createIpcServer({
6044
6057
  description,
6045
6058
  agentName: _client.agentName,
6046
6059
  })
6047
- // Compact action row: ❌ Deny · ✅ Allow once · 🔁 Always… — the
6048
- // scope of an "always" grant stays hidden until the operator taps
6049
- // "🔁 Always…", which swaps the row for a scope choice (this file /
6050
- // any file ⚠️). The "🔁 Always…" button only appears when we can
6051
- // synthesize a meaningful rule for this tool; unknown tools get the
6052
- // two-button row only.
6053
- const scopeChoices = resolveScopedAllowChoices(toolName, inputPreview)
6054
- const showAlways = scopeChoices != null
6055
- // Offer "⏱ 30 min" only for a narrow, non-destructive scope, and only
6056
- // when the tier is enabled (TTL > 0).
6057
- const showTimeBox = scopedApprovalTtlMs() > 0 &&
6058
- resolveTimeBox(toolName, inputPreview, scopeChoices) != null
6059
- const keyboard = buildPermissionActionRow(requestId, showAlways, showTimeBox)
6060
+ // Compact action row: ❌ Deny · ✅ Allow · 🔁 Always… — the scope of an
6061
+ // "always" grant stays hidden until the operator taps "🔁 Always…",
6062
+ // which swaps the row for a scope choice (this file / any file ⚠️). The
6063
+ // "🔁 Always…" button only appears when we can synthesize a meaningful
6064
+ // rule for this tool; unknown tools get the two-button row only. "Allow"
6065
+ // itself auto-grants a 30-min window for narrow non-destructive scopes
6066
+ // (decided in the allow handler), so there is no separate time-box button.
6067
+ const showAlways = resolveScopedAllowChoices(toolName, inputPreview) != null
6068
+ const keyboard = buildPermissionActionRow(requestId, showAlways)
6060
6069
  // Route the card to the SAME place the post-verdict resume message
6061
6070
  // lands (resolvePermissionCardTargets): the ORIGINATING chat+topic when
6062
6071
  // there's an active turn — so a supergroup agent's card appears IN the
@@ -14219,6 +14228,51 @@ bot.command('model', async ctx => {
14219
14228
  await switchroomReply(ctx, reply.text, { html: reply.html })
14220
14229
  })
14221
14230
 
14231
+ // `/effort` — show or switch the reasoning effort for the live session.
14232
+ // The effort sibling of `/model`: bare form renders a five-button menu
14233
+ // (low/medium/high/xhigh/max, the live level ✅), a typed form
14234
+ // `/effort <level>` sets it directly. Both ride the allowlisted inject
14235
+ // primitive (claude's own `/effort` REPL command), session-scoped — boot
14236
+ // re-pins the configured default via start.sh's `--effort`. Implementation
14237
+ // in effort-command.ts so it's unit-testable without booting the bot.
14238
+ function buildEffortDeps(): EffortCommandDeps {
14239
+ return {
14240
+ inject: injectSlashCommandImpl,
14241
+ getAgentName: getMyAgentName,
14242
+ getConfiguredEffort: () => {
14243
+ type AgentListResp = { agents: Array<{ name: string; thinking_effort?: string | null }> }
14244
+ const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
14245
+ return data?.agents?.find(a => a.name === getMyAgentName())?.thinking_effort ?? null
14246
+ },
14247
+ escapeHtml: escapeHtmlForTg,
14248
+ preBlock,
14249
+ }
14250
+ }
14251
+
14252
+ function effortMenuReplyMarkup(reply: EffortMenuReply): InlineKeyboard | undefined {
14253
+ if (!reply.keyboard) return undefined
14254
+ const kb = new InlineKeyboard()
14255
+ for (const row of reply.keyboard) {
14256
+ for (const btn of row) kb.text(btn.text, btn.callback_data)
14257
+ kb.row()
14258
+ }
14259
+ return kb
14260
+ }
14261
+
14262
+ bot.command('effort', async ctx => {
14263
+ if (!isAuthorizedSender(ctx)) return
14264
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
14265
+ const parsed = parseEffortCommand(text) ?? { kind: 'show' as const }
14266
+ const deps = buildEffortDeps()
14267
+ if (parsed.kind === 'show') {
14268
+ const menu = buildEffortMenu(deps)
14269
+ await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) })
14270
+ return
14271
+ }
14272
+ const reply = await handleEffortCommand(parsed, deps)
14273
+ await switchroomReply(ctx, reply.text, { html: reply.html })
14274
+ })
14275
+
14222
14276
  bot.command('agentstart', async ctx => {
14223
14277
  if (!isAuthorizedSender(ctx)) return
14224
14278
  const name = ctx.match?.trim() || getMyAgentName()
@@ -18608,6 +18662,33 @@ bot.on('callback_query:data', async ctx => {
18608
18662
  // a stale index); `mdl:r` re-renders. Strict allowFrom gate like
18609
18663
  // every other mutating callback family — a model switch changes the
18610
18664
  // fleet's quota burn profile.
18665
+ if (data.startsWith(EFFORT_CALLBACK_PREFIX)) {
18666
+ const access = loadAccess()
18667
+ const senderId = String(ctx.from?.id ?? '')
18668
+ if (!access.allowFrom.includes(senderId)) {
18669
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
18670
+ return
18671
+ }
18672
+ // No picker-driving (the inline `/effort <level>` form sets it
18673
+ // directly via the inject primitive), so no mid-turn guard is needed
18674
+ // here — just ack and apply.
18675
+ await ctx.answerCallbackQuery({ text: 'Setting effort…' }).catch(() => {})
18676
+ try {
18677
+ const outcome = await handleEffortMenuCallback(data, buildEffortDeps())
18678
+ await ctx
18679
+ .editMessageText(outcome.reply.text, {
18680
+ parse_mode: 'HTML',
18681
+ reply_markup: effortMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
18682
+ })
18683
+ .catch(() => {})
18684
+ } catch (err) {
18685
+ process.stderr.write(
18686
+ `telegram gateway: effort-menu callback failed: ${(err as Error)?.message ?? String(err)}\n`,
18687
+ )
18688
+ }
18689
+ return
18690
+ }
18691
+
18611
18692
  if (data.startsWith(MODEL_CALLBACK_PREFIX)) {
18612
18693
  const access = loadAccess()
18613
18694
  const senderId = String(ctx.from?.id ?? '')
@@ -19073,7 +19154,7 @@ bot.on('callback_query:data', async ctx => {
19073
19154
  }
19074
19155
 
19075
19156
  // Permission request buttons.
19076
- const m = /^perm:(allow|deny|always|asn|asb|back|tmb):([a-km-z]{5})$/.exec(data)
19157
+ const m = /^perm:(allow|deny|always|asn|asb|back):([a-km-z]{5})$/.exec(data)
19077
19158
  if (!m) { await ctx.answerCallbackQuery().catch(() => {}); return }
19078
19159
  const access = loadAccess()
19079
19160
  const senderId = String(ctx.from.id)
@@ -19088,10 +19169,7 @@ bot.on('callback_query:data', async ctx => {
19088
19169
  if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
19089
19170
  let keyboard: InlineKeyboard
19090
19171
  if (behavior === 'back') {
19091
- const backChoices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
19092
- const backTimeBox = scopedApprovalTtlMs() > 0 &&
19093
- resolveTimeBox(details.tool_name, details.input_preview, backChoices) != null
19094
- keyboard = buildPermissionActionRow(request_id, true, backTimeBox)
19172
+ keyboard = buildPermissionActionRow(request_id, true)
19095
19173
  } else {
19096
19174
  const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
19097
19175
  if (choices == null) {
@@ -19315,69 +19393,47 @@ bot.on('callback_query:data', async ctx => {
19315
19393
  return
19316
19394
  }
19317
19395
 
19318
- // "⏱ 30 min" record a fixed-window scoped grant for the NARROW scope,
19319
- // then allow the in-flight call. Mirrors the asn/asb structure: dispatch
19320
- // the verdict immediately, then edit the card. CRITICAL: the verdict
19321
- // carries NO `rule` (unlike asn/asb), so the bridge does not cache it
19322
- // untimed the window lives only in scopedGrants, gateway-side.
19323
- if (behavior === 'tmb') {
19324
- const details = pendingPermissions.get(request_id)
19325
- if (!details) { await ctx.answerCallbackQuery({ text: 'Details no longer available.' }).catch(() => {}); return }
19326
- const ttl = scopedApprovalTtlMs()
19327
- if (ttl <= 0) { await ctx.answerCallbackQuery({ text: 'Time-boxed approvals are disabled.' }).catch(() => {}); return }
19328
- const choices = resolveScopedAllowChoices(details.tool_name, details.input_preview)
19329
- const tb = resolveTimeBox(details.tool_name, details.input_preview, choices)
19330
- if (!tb) { await ctx.answerCallbackQuery({ text: 'This action can\'t be time-boxed.' }).catch(() => {}); return }
19331
- const agentName = selfAgentName()
19332
- if (!agentName) { await ctx.answerCallbackQuery({ text: 'Time-box needs SWITCHROOM_AGENT_NAME — gateway is misconfigured.' }).catch(() => {}); return }
19333
-
19334
- pendingPermissions.delete(request_id)
19335
- // (1) Allow the in-flight call NOW — no `rule` (keeps the window
19336
- // strictly gateway-side; the bridge must not cache it untimed).
19337
- dispatchPermissionVerdict({ type: 'permission', requestId: request_id, behavior: 'allow' })
19338
- // (2) Record the fixed-window grant so matching calls auto-allow.
19339
- recordScopedGrant(scopedGrants, agentName, tb.rule, Date.now(), ttl)
19340
- resumeReactionAfterVerdict()
19341
- postPermissionResumeMessage({
19342
- behavior: 'allow',
19343
- action: naturalAction(details.tool_name, details.input_preview),
19344
- })
19396
+ // Forward permission decision to connected bridges. Capture the pending
19397
+ // details BEFORE deleting the entry the resume message names the resumed
19398
+ // work, and "Allow" on a narrow non-destructive scope auto-grants a 30-min
19399
+ // window (resolveTimeBox) so the same action stops re-asking. This IS the
19400
+ // default behavior of Allow (no separate button); broad / MCP / destructive
19401
+ // scopes (resolveTimeBox null) and the disabled tier (ttl<=0) stay truly
19402
+ // once. The verdict is still dispatched WITHOUT a `rule` (below), so the
19403
+ // bridge never caches it untimed the window lives only in scopedGrants.
19404
+ const pd = pendingPermissions.get(request_id)
19405
+ const resumeAction = pd ? naturalAction(pd.tool_name, pd.input_preview) : ''
19406
+ const scopedTtl = scopedApprovalTtlMs()
19407
+ const timeBox = (behavior === 'allow' && scopedTtl > 0 && pd)
19408
+ ? resolveTimeBox(pd.tool_name, pd.input_preview, resolveScopedAllowChoices(pd.tool_name, pd.input_preview))
19409
+ : null
19410
+ const grantAgent = selfAgentName()
19411
+ pendingPermissions.delete(request_id)
19412
+ if (timeBox && grantAgent) {
19413
+ recordScopedGrant(scopedGrants, grantAgent, timeBox.rule, Date.now(), scopedTtl)
19345
19414
  process.stderr.write(
19346
- `telegram gateway: scoped-approval granted rule="${tb.rule}" agent=${agentName} ` +
19347
- `ttl_ms=${ttl} (request_id=${request_id})\n`,
19415
+ `telegram gateway: scoped-approval granted via Allow rule="${timeBox.rule}" ` +
19416
+ `agent=${grantAgent} ttl_ms=${scopedTtl} (request_id=${request_id})\n`,
19348
19417
  )
19349
-
19350
- const mins = Math.max(1, Math.round(ttl / 60_000))
19351
- // Honest card: state the real BREADTH (e.g. "any `git` command"), not
19352
- // just the rule, plus the window — consent covers both (access-model
19353
- // honest-card contract).
19354
- const sourceMsg = ctx.callbackQuery?.message
19355
- const baseText = sourceMsg && 'text' in sourceMsg && sourceMsg.text
19356
- ? escapeHtmlForTg(sourceMsg.text)
19357
- : ''
19358
- const editLabel = `⏱ <b>Allowed for ${mins} min — ${escapeHtmlForTg(tb.breadth)}</b> · re-asks after that, and now for anything else`
19359
- await finalizeCallback(ctx, {
19360
- ackText: `⏱ Allowed for ${mins} min`.slice(0, 200),
19361
- newText: baseText ? `${baseText}\n\n${editLabel}` : editLabel,
19362
- parseMode: 'HTML',
19363
- })
19364
- return
19365
19418
  }
19366
-
19367
- // Forward permission decision to connected bridges. Capture the work
19368
- // phrase BEFORE deleting the pending entry — postPermissionResumeMessage
19369
- // (fired in synthInbound below) names the resumed work.
19370
- const resumeAction = (() => {
19371
- const d = pendingPermissions.get(request_id)
19372
- return d ? naturalAction(d.tool_name, d.input_preview) : ''
19373
- })()
19374
- pendingPermissions.delete(request_id)
19419
+ const scopedMins = Math.max(1, Math.round(scopedTtl / 60_000))
19375
19420
  // The card collapses to a plain verdict label. The distinct agent-voiced
19376
19421
  // "got it, continuing: …" message (posted on resume below) now carries
19377
19422
  // the "is it working or did my tap do nothing?" signal the old
19378
19423
  // `▶️ resuming…` card footnote used to — and names the work, which the
19379
19424
  // footnote never did. Keeps the card terse and the resume legible.
19380
- const label = behavior === 'allow' ? '✅ Allowed' : '❌ Denied'
19425
+ // Honest-card: only claim the window when one was actually recorded
19426
+ // (timeBox eligible AND we had an agent name to key it under) — never
19427
+ // promise "won't ask again for 30 min" if nothing was stored.
19428
+ const windowGranted = timeBox != null && grantAgent !== ''
19429
+ const ackText = behavior === 'deny'
19430
+ ? '❌ Denied'
19431
+ : (windowGranted ? `✅ Allowed (${scopedMins} min)` : '✅ Allowed once')
19432
+ const htmlLabel = behavior === 'deny'
19433
+ ? '❌ <b>Denied</b>'
19434
+ : (windowGranted
19435
+ ? `✅ <b>Allowed — won't ask again about ${escapeHtmlForTg(timeBox!.breadth)} for ${scopedMins} min</b>`
19436
+ : '✅ <b>Allowed once</b>')
19381
19437
  // HTML-escape the source text — same hazard as the scope-commit and
19382
19438
  // recent-denial paths above. The permission card body
19383
19439
  // (formatPermissionCardBody) appends claude-supplied `description`
@@ -19396,10 +19452,12 @@ bot.on('callback_query:data', async ctx => {
19396
19452
  // permission was already broadcast. Routing through finalizeCallback
19397
19453
  // strips the keyboard atomically with the status-line edit.
19398
19454
  await finalizeCallback(ctx, {
19399
- ackText: label,
19400
- newText: baseText ? `${baseText}\n\n${label}` : label,
19455
+ ackText: ackText.slice(0, 200),
19456
+ newText: baseText ? `${baseText}\n\n${htmlLabel}` : htmlLabel,
19401
19457
  parseMode: 'HTML',
19402
19458
  synthInbound: () => {
19459
+ // No `rule` → the bridge does NOT cache this (truly once on the bridge);
19460
+ // any 30-min stickiness lives only in scopedGrants (recorded above).
19403
19461
  dispatchPermissionVerdict({
19404
19462
  type: 'permission',
19405
19463
  requestId: request_id,
@@ -1,9 +1,13 @@
1
1
  /**
2
- * Scoped, time-boxed approval — the "⏱ 30 min" tier, the middle rung
3
- * between "✅ Allow once" (re-prompts on the very next call) and
4
- * "🔁 Always…" (a durable `tools.allow` write that lasts forever). After
5
- * the operator taps "⏱ 30 min" on a permission card, byte-identical
6
- * in-scope requests auto-allow for a fixed window without re-carding.
2
+ * Scoped, time-boxed approval — the default behavior of the "✅ Allow" tap
3
+ * for a NARROW, non-destructive scope. Tapping Allow on such a request
4
+ * auto-grants a fixed window so byte-identical in-scope requests auto-allow
5
+ * without re-carding (killing tap-fatigue on re-edits / re-runs). Broad /
6
+ * MCP / destructive scopes get no window Allow stays truly once for them.
7
+ * "🔁 Always…" remains the separate durable (`tools.allow`, forever) tier.
8
+ * There is deliberately no separate time-box button: the window IS what
9
+ * "Allow" means for a narrow safe scope, disclosed honestly on the post-tap
10
+ * card ("won't ask again about <breadth> for 30 min" vs "allowed once").
7
11
  *
8
12
  * Design contract (reference/access-model.md — "you hold the leash"):
9
13
  *
@@ -21,14 +25,13 @@
21
25
  * - **Conservative scope (this tier, v1).** Only the *narrow* scope is
22
26
  * ever time-boxed: an exact file path (`Edit(/x.ts)`) or a Bash
23
27
  * command-family (`Bash(git:*)`). Broad scopes ("any file", resource-
24
- * blind MCP, "any command") are NOT offered the button they stay
25
- * once / always. This covers the real fatigue (re-editing the same
28
+ * blind MCP, "any command") get NO window — Allow stays truly once for them. This covers the real fatigue (re-editing the same
26
29
  * file, re-running a safe command) without fanning one tap across an
27
30
  * unbounded action set.
28
31
  * - **Fail-closed on irreversible.** A Bash family grant (`Bash(git:*)`)
29
32
  * must never auto-allow a destructive member of that family
30
33
  * (`git push --force`, `git reset --hard`). `isDestructiveBashCommand`
31
- * is re-checked at BOTH grant time (don't offer ) and match time
34
+ * is re-checked at BOTH grant time (no window granted) and match time
32
35
  * (a cached family grant fails closed → re-cards) so per-call consent
33
36
  * for irreversible actions is preserved.
34
37
  *
@@ -45,8 +48,8 @@ export const SCOPED_APPROVAL_DEFAULT_TTL_MS = 30 * 60 * 1000;
45
48
 
46
49
  /**
47
50
  * Resolve the configured window from the environment. `0` (or negative)
48
- * disables the tierthe gateway hides the button and never
49
- * short-circuits. A blank/garbage value falls back to the 30-min default.
51
+ * disables the windowAllow becomes truly once for every scope and the
52
+ * gateway never short-circuits. A blank/garbage value falls back to the 30-min default.
50
53
  * Kill-switch: `SWITCHROOM_SCOPED_APPROVAL_TTL_MS=0`.
51
54
  */
52
55
  export function scopedApprovalTtlMs(
@@ -84,7 +87,7 @@ const BASH_FAMILY_RULE = /^Bash\(([^:]+):\*\)$/;
84
87
  * Conservative time-box eligibility. Given the already-resolved scope
85
88
  * choices for a permission request, return the NARROW rule to time-box
86
89
  * plus an honest breadth phrase — or `null` when this request must not
87
- * get a button (broad-only tools, MCP, Skill, a destructive Bash
90
+ * get a window (broad-only tools, MCP, Skill, a destructive Bash
88
91
  * command, or any tool with no narrow sub-scope).
89
92
  *
90
93
  * - File tools with an exact path → time-boxable (bounded to the one