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.
- package/dist/cli/switchroom.js +215 -24
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +245 -71
- package/telegram-plugin/gateway/effort-command.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +142 -84
- package/telegram-plugin/scoped-approval.ts +14 -11
- package/telegram-plugin/tests/effort-command.test.ts +191 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +2 -2
- package/telegram-plugin/welcome-text.ts +5 -0
|
@@ -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 <level></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
|
-
//
|
|
3664
|
-
//
|
|
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
|
|
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
|
|
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
|
|
6048
|
-
//
|
|
6049
|
-
//
|
|
6050
|
-
//
|
|
6051
|
-
//
|
|
6052
|
-
//
|
|
6053
|
-
|
|
6054
|
-
const showAlways =
|
|
6055
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
19319
|
-
//
|
|
19320
|
-
//
|
|
19321
|
-
//
|
|
19322
|
-
//
|
|
19323
|
-
|
|
19324
|
-
|
|
19325
|
-
|
|
19326
|
-
|
|
19327
|
-
|
|
19328
|
-
|
|
19329
|
-
|
|
19330
|
-
|
|
19331
|
-
|
|
19332
|
-
|
|
19333
|
-
|
|
19334
|
-
|
|
19335
|
-
|
|
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="${
|
|
19347
|
-
`ttl_ms=${
|
|
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
|
-
|
|
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:
|
|
19400
|
-
newText: baseText ? `${baseText}\n\n${
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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")
|
|
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 (
|
|
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
|
|
49
|
-
* short-circuits. A blank/garbage value falls back to the 30-min default.
|
|
51
|
+
* disables the window — Allow 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
|
|
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
|