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.
- package/dist/cli/switchroom.js +521 -336
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +24 -15
- package/telegram-plugin/dist/gateway/gateway.js +304 -6
- package/telegram-plugin/gateway/effort-command.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +199 -2
- package/telegram-plugin/gateway/grant-restart.ts +30 -0
- package/telegram-plugin/tests/effort-command.test.ts +191 -0
- package/telegram-plugin/tests/grant-restart.test.ts +38 -0
- package/telegram-plugin/welcome-text.ts +7 -1
|
@@ -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'
|
|
@@ -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
|
-
:
|
|
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
|
+
}
|