switchroom 0.15.18 → 0.15.20

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'
@@ -429,6 +438,7 @@ import {
429
438
  lookupScopedGrant,
430
439
  sweepScopedGrants,
431
440
  } from '../scoped-approval.js'
441
+ import { grantRestartDecision, type GrantRestartDecision } from './grant-restart.js'
432
442
  import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
433
443
  import {
434
444
  readClaudeJsonOverage,
@@ -13392,6 +13402,54 @@ async function sweepBeforeSelfRestart(): Promise<void> {
13392
13402
  }
13393
13403
  }
13394
13404
 
13405
+ /**
13406
+ * Schedule a marker-safe, turn-deferred SELF-restart so a just-persisted
13407
+ * config change ACTUALLY takes effect. claude loads tools / MCP servers /
13408
+ * settings at process start, so a durable "Always allow" write is inert in
13409
+ * the running session until the next restart — the old "restart agent for
13410
+ * full effect" text asked the operator to do this by hand, and most never
13411
+ * did (so the grant didn't stick and the same prompt reappeared). This
13412
+ * completes the flow the operator already approved.
13413
+ *
13414
+ * CALLER-AGENT ONLY (an "Always allow" edits the calling agent's own
13415
+ * `agents.<self>.tools.allow`, a provably single-agent blast radius). The
13416
+ * restart fires when the CURRENT turn completes (via `pendingRestarts`),
13417
+ * never mid-turn — so the operator-approved action finishes first. If no
13418
+ * turn is in flight, it fires immediately after a short drain delay. A
13419
+ * marker is written first so the post-restart greeting lands in the chat
13420
+ * the operator tapped. Kill-switch: `SWITCHROOM_AUTORESTART_ON_GRANT=0`.
13421
+ *
13422
+ * Returns 'disabled' | 'deferred' | 'now'.
13423
+ */
13424
+ function scheduleGrantRestart(
13425
+ agentName: string,
13426
+ chatId: string | number | undefined,
13427
+ threadId: number | undefined,
13428
+ reason: string,
13429
+ ): GrantRestartDecision {
13430
+ const decision = grantRestartDecision({
13431
+ killSwitch: process.env.SWITCHROOM_AUTORESTART_ON_GRANT,
13432
+ selfAgent: process.env.SWITCHROOM_AGENT_NAME,
13433
+ agentName,
13434
+ turnInFlight: turnInFlightForGate(),
13435
+ })
13436
+ if (decision === "disabled") return decision
13437
+ // Marker first → the post-restart greeting lands in the chat the operator
13438
+ // tapped, not the default operator DM.
13439
+ if (chatId != null) {
13440
+ writeRestartMarker({ chat_id: String(chatId), thread_id: threadId ?? null, ack_message_id: null, ts: Date.now() })
13441
+ }
13442
+ stampUserRestartReason(reason)
13443
+ if (decision === "deferred") {
13444
+ pendingRestarts.set(agentName, Date.now()) // fires at turn-complete (marker-safe)
13445
+ } else {
13446
+ // No turn to drain — restart now, after a short delay so this callback's
13447
+ // card edit flushes first.
13448
+ void sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName, reason, 1500))
13449
+ }
13450
+ return decision
13451
+ }
13452
+
13395
13453
  /**
13396
13454
  * Shape the `switchroom auth ...` CLI stdout into a Telegram-friendly
13397
13455
  * HTML block. Returns the body text AND the OAuth authorize URL (if
@@ -14219,6 +14277,51 @@ bot.command('model', async ctx => {
14219
14277
  await switchroomReply(ctx, reply.text, { html: reply.html })
14220
14278
  })
14221
14279
 
14280
+ // `/effort` — show or switch the reasoning effort for the live session.
14281
+ // The effort sibling of `/model`: bare form renders a five-button menu
14282
+ // (low/medium/high/xhigh/max, the live level ✅), a typed form
14283
+ // `/effort <level>` sets it directly. Both ride the allowlisted inject
14284
+ // primitive (claude's own `/effort` REPL command), session-scoped — boot
14285
+ // re-pins the configured default via start.sh's `--effort`. Implementation
14286
+ // in effort-command.ts so it's unit-testable without booting the bot.
14287
+ function buildEffortDeps(): EffortCommandDeps {
14288
+ return {
14289
+ inject: injectSlashCommandImpl,
14290
+ getAgentName: getMyAgentName,
14291
+ getConfiguredEffort: () => {
14292
+ type AgentListResp = { agents: Array<{ name: string; thinking_effort?: string | null }> }
14293
+ const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
14294
+ return data?.agents?.find(a => a.name === getMyAgentName())?.thinking_effort ?? null
14295
+ },
14296
+ escapeHtml: escapeHtmlForTg,
14297
+ preBlock,
14298
+ }
14299
+ }
14300
+
14301
+ function effortMenuReplyMarkup(reply: EffortMenuReply): InlineKeyboard | undefined {
14302
+ if (!reply.keyboard) return undefined
14303
+ const kb = new InlineKeyboard()
14304
+ for (const row of reply.keyboard) {
14305
+ for (const btn of row) kb.text(btn.text, btn.callback_data)
14306
+ kb.row()
14307
+ }
14308
+ return kb
14309
+ }
14310
+
14311
+ bot.command('effort', async ctx => {
14312
+ if (!isAuthorizedSender(ctx)) return
14313
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? ''
14314
+ const parsed = parseEffortCommand(text) ?? { kind: 'show' as const }
14315
+ const deps = buildEffortDeps()
14316
+ if (parsed.kind === 'show') {
14317
+ const menu = buildEffortMenu(deps)
14318
+ await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) })
14319
+ return
14320
+ }
14321
+ const reply = await handleEffortCommand(parsed, deps)
14322
+ await switchroomReply(ctx, reply.text, { html: reply.html })
14323
+ })
14324
+
14222
14325
  bot.command('agentstart', async ctx => {
14223
14326
  if (!isAuthorizedSender(ctx)) return
14224
14327
  const name = ctx.match?.trim() || getMyAgentName()
@@ -18568,6 +18671,55 @@ bot.command('version', async ctx => {
18568
18671
  })
18569
18672
 
18570
18673
 
18674
+ // /whoami — the operator's view of THIS agent's sandbox (the same
18675
+ // `config whoami` the agent itself can call as an MCP tool, and the host CLI
18676
+ // exposes). Read-only, isAuthorizedSender-gated like /version — surfaces
18677
+ // tools / MCP / vault key-NAMES (never values) / powers so the operator can
18678
+ // see at a glance what this agent is authorized for.
18679
+ bot.command('whoami', async ctx => {
18680
+ if (!isAuthorizedSender(ctx)) return
18681
+ try {
18682
+ let raw: string
18683
+ try { raw = switchroomExecCombined(['config', 'whoami'], 10000) }
18684
+ catch (err: unknown) { raw = (err as any).stdout ?? (err as any).message ?? 'whoami failed' }
18685
+ const trimmed = stripAnsi(raw).trim()
18686
+ let card: string
18687
+ try { card = formatWhoamiCard(JSON.parse(trimmed.split('\n').pop() ?? trimmed)) }
18688
+ catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
18689
+ await switchroomReply(ctx, card, { html: true })
18690
+ } catch (err: unknown) {
18691
+ await switchroomReply(ctx, `<b>whoami failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
18692
+ }
18693
+ })
18694
+
18695
+ /** Compact HTML card from the `config whoami` JSON view. Names/booleans only. */
18696
+ function formatWhoamiCard(v: {
18697
+ name?: string; persona?: string | null; model?: string | null; tier?: string;
18698
+ tools?: { allow?: string[]; deny?: string[] }; mcpServers?: string[]; skills?: string[];
18699
+ vault?: { key: string; readable: boolean }[];
18700
+ powers?: { admin?: boolean; root?: boolean; configEdit?: boolean; crossAgentHostVerbs?: boolean };
18701
+ scheduleCount?: number; memoryBackend?: string | null;
18702
+ }): string {
18703
+ const esc = escapeHtmlForTg
18704
+ const yn = (b?: boolean) => (b ? '✓' : '✗')
18705
+ const lines: string[] = []
18706
+ lines.push(`👤 <b>${esc(v.name ?? '?')}</b> · ${esc(v.tier ?? 'standard')}`)
18707
+ if (v.persona) lines.push(esc(v.persona))
18708
+ if (v.model) lines.push(`Model: ${esc(v.model)}`)
18709
+ const allow = v.tools?.allow ?? []
18710
+ lines.push(`Tools: ${allow.length ? esc(allow.slice(0, 8).join(', ')) + (allow.length > 8 ? ` …(+${allow.length - 8})` : '') : '—'}`)
18711
+ if ((v.tools?.deny ?? []).length) lines.push(`Denied: ${esc((v.tools!.deny!).join(', '))}`)
18712
+ if ((v.mcpServers ?? []).length) lines.push(`MCP: ${esc(v.mcpServers!.join(', '))}`)
18713
+ if ((v.skills ?? []).length) lines.push(`Skills: ${esc(v.skills!.join(', '))}`)
18714
+ if ((v.vault ?? []).length) {
18715
+ lines.push(`Vault keys (names only): ${v.vault!.map(k => `${esc(k.key)} ${yn(k.readable)}`).join(', ')}`)
18716
+ }
18717
+ const p = v.powers ?? {}
18718
+ lines.push(`Powers: admin ${yn(p.admin)} · root ${yn(p.root)} · config-edit ${yn(p.configEdit)} · cross-agent verbs ${yn(p.crossAgentHostVerbs)}`)
18719
+ lines.push(`Schedule: ${v.scheduleCount ?? 0} cron · Memory: ${esc(v.memoryBackend ?? 'none')}`)
18720
+ return lines.join('\n')
18721
+ }
18722
+
18571
18723
  bot.command('commands', async ctx => {
18572
18724
  if (!isAuthorizedSender(ctx)) return
18573
18725
  await switchroomReply(ctx, buildSwitchroomHelpText(getMyAgentName()), { html: true })
@@ -18608,6 +18760,33 @@ bot.on('callback_query:data', async ctx => {
18608
18760
  // a stale index); `mdl:r` re-renders. Strict allowFrom gate like
18609
18761
  // every other mutating callback family — a model switch changes the
18610
18762
  // fleet's quota burn profile.
18763
+ if (data.startsWith(EFFORT_CALLBACK_PREFIX)) {
18764
+ const access = loadAccess()
18765
+ const senderId = String(ctx.from?.id ?? '')
18766
+ if (!access.allowFrom.includes(senderId)) {
18767
+ await ctx.answerCallbackQuery({ text: 'Not authorized.' })
18768
+ return
18769
+ }
18770
+ // No picker-driving (the inline `/effort <level>` form sets it
18771
+ // directly via the inject primitive), so no mid-turn guard is needed
18772
+ // here — just ack and apply.
18773
+ await ctx.answerCallbackQuery({ text: 'Setting effort…' }).catch(() => {})
18774
+ try {
18775
+ const outcome = await handleEffortMenuCallback(data, buildEffortDeps())
18776
+ await ctx
18777
+ .editMessageText(outcome.reply.text, {
18778
+ parse_mode: 'HTML',
18779
+ reply_markup: effortMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
18780
+ })
18781
+ .catch(() => {})
18782
+ } catch (err) {
18783
+ process.stderr.write(
18784
+ `telegram gateway: effort-menu callback failed: ${(err as Error)?.message ?? String(err)}\n`,
18785
+ )
18786
+ }
18787
+ return
18788
+ }
18789
+
18611
18790
  if (data.startsWith(MODEL_CALLBACK_PREFIX)) {
18612
18791
  const access = loadAccess()
18613
18792
  const senderId = String(ctx.from?.id ?? '')
@@ -19275,10 +19454,26 @@ bot.on('callback_query:data', async ctx => {
19275
19454
 
19276
19455
  const ok = durable
19277
19456
  const legacyNote = legacy && durable
19457
+ // Make the durable grant LIVE: config_propose_edit's apply already
19458
+ // regenerated the scaffold (settings.json with the new tools.allow), so a
19459
+ // marker-safe, turn-deferred self-restart loads it — no more "restart by
19460
+ // hand" hint that the operator ignores (leaving the grant inert until the
19461
+ // next bounce). Only on the hostd-durable path (scaffold currency assured);
19462
+ // legacy path keeps the manual hint. Self-agent only; kill-switch
19463
+ // SWITCHROOM_AUTORESTART_ON_GRANT=0.
19464
+ const restartScheduled =
19465
+ ok && !legacy &&
19466
+ scheduleGrantRestart(
19467
+ agentName,
19468
+ ctx.chat?.id,
19469
+ (ctx.callbackQuery?.message as { message_thread_id?: number } | undefined)?.message_thread_id,
19470
+ `always-allow: ${grantPhrase}`,
19471
+ ) !== "disabled"
19472
+ const liveSuffix = restartScheduled ? " — applying now (restarting to take effect)" : ""
19278
19473
  const ackText = ok
19279
19474
  ? (legacyNote
19280
19475
  ? `✅ Saved. ${agentName} can now ${grantPhrase} without asking (legacy path).`
19281
- : `✅ Saved. ${agentName} can now ${grantPhrase} without asking.`)
19476
+ : `✅ Saved. ${agentName} can now ${grantPhrase} without asking.${liveSuffix}`)
19282
19477
  : (editLockHint
19283
19478
  ? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
19284
19479
  : `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
@@ -19295,7 +19490,9 @@ bot.on('callback_query:data', async ctx => {
19295
19490
  const editLabel = ok
19296
19491
  ? (legacyNote
19297
19492
  ? `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking (legacy path); restart agent for full effect`
19298
- : `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect`)
19493
+ : restartScheduled
19494
+ ? `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking — applying now (restarting to take effect).`
19495
+ : `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect`)
19299
19496
  : (editLockHint
19300
19497
  ? `⚠️ <b>Allowed for now — "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.`
19301
19498
  : `⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`)
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Pure decision for the "make a just-persisted grant LIVE" self-restart
3
+ * (the side-effecting `scheduleGrantRestart` in gateway.ts wraps this).
4
+ *
5
+ * Extracted so the gating — kill-switch, self-agent-only, and
6
+ * turn-deferred-vs-now — unit-tests without gateway.ts's boot side-effects
7
+ * (same pattern as scoped-approval.ts / admin-commands/index.ts).
8
+ *
9
+ * Contract (reference/access-model.md): the restart only ever follows an
10
+ * operator-approved, single-agent, additive `tools.allow` edit, and only
11
+ * ever bounces the CALLER's own agent — never a peer, never fleet-wide.
12
+ */
13
+ export type GrantRestartDecision = "disabled" | "deferred" | "now";
14
+
15
+ export function grantRestartDecision(opts: {
16
+ /** SWITCHROOM_AUTORESTART_ON_GRANT value ("0" disables; default on). */
17
+ killSwitch: string | undefined;
18
+ /** The gateway's own agent identity ($SWITCHROOM_AGENT_NAME). */
19
+ selfAgent: string | undefined;
20
+ /** The agent whose config was edited (must equal selfAgent — self only). */
21
+ agentName: string;
22
+ /** Whether a turn is currently in flight (defer the restart to its end). */
23
+ turnInFlight: boolean;
24
+ }): GrantRestartDecision {
25
+ if ((opts.killSwitch ?? "") === "0") return "disabled";
26
+ // Self-only: an "Always allow" edits agents.<self>.tools.allow. We never
27
+ // bounce a peer or the fleet from this path.
28
+ if (!opts.selfAgent || opts.selfAgent !== opts.agentName) return "disabled";
29
+ return opts.turnInFlight ? "deferred" : "now";
30
+ }