switchroom 0.15.18 → 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.
@@ -25739,6 +25739,7 @@ var init_inject = __esm(() => {
25739
25739
  ["/hooks", { description: "List configured hooks", expectsOutput: true }],
25740
25740
  ["/memory", { description: "Open memory picker", expectsOutput: true }],
25741
25741
  ["/model", { description: "Open model picker", expectsOutput: true }],
25742
+ ["/effort", { description: "Set reasoning effort", expectsOutput: true }],
25742
25743
  [
25743
25744
  "/clear",
25744
25745
  { description: "Clear session screen", expectsOutput: false }
@@ -50459,8 +50460,8 @@ var {
50459
50460
  } = import__.default;
50460
50461
 
50461
50462
  // src/build-info.ts
50462
- var VERSION = "0.15.18";
50463
- var COMMIT_SHA = "d7c044b9";
50463
+ var VERSION = "0.15.19";
50464
+ var COMMIT_SHA = "3f40c6f9";
50464
50465
 
50465
50466
  // src/cli/agent.ts
50466
50467
  init_source();
@@ -57061,6 +57062,7 @@ function registerAgentCommand(program3) {
57061
57062
  status: status?.active ?? "unknown",
57062
57063
  uptime: formatUptime2(status?.uptime ?? null),
57063
57064
  model: resolved.model ?? SWITCHROOM_DEFAULT_MAIN_MODEL,
57065
+ thinking_effort: resolved.thinking_effort ?? SWITCHROOM_DEFAULT_THINKING_EFFORT,
57064
57066
  extends: agentConfig.extends ?? "default",
57065
57067
  topic_name: agentConfig.topic_name,
57066
57068
  topic_emoji: agentConfig.topic_emoji,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.18",
3
+ "version": "0.15.19",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43144,6 +43144,7 @@ var TELEGRAM_MENU_COMMANDS = [
43144
43144
  { command: "logs", description: "Show recent agent logs" },
43145
43145
  { command: "inject", description: "Inject a Claude Code slash command (e.g. /cost)" },
43146
43146
  { command: "model", description: "Show or switch the Claude model" },
43147
+ { command: "effort", description: "Show or switch the reasoning effort" },
43147
43148
  { command: "doctor", description: "Health check (deps, services, MCP)" },
43148
43149
  { command: "usage", description: "Pro/Max plan quota (5h + 7d windows)" },
43149
43150
  { command: "vault", description: "Manage vault secrets + capability grants" },
@@ -43185,6 +43186,7 @@ function switchroomHelpText(agentName3) {
43185
43186
  `<code>/auth rm [agent] &lt;slot&gt; [--force]</code> \u2014 remove a slot`,
43186
43187
  `<code>/model</code> \u2014 show the configured Claude model`,
43187
43188
  `<code>/model &lt;name&gt;</code> \u2014 switch the live session's model (opus \u00b7 sonnet \u00b7 haiku or a full id; until restart)`,
43189
+ `<code>/effort</code> \u2014 show or switch reasoning effort (low \u00b7 medium \u00b7 high \u00b7 xhigh \u00b7 max; until restart)`,
43188
43190
  `<code>/topics</code> \u2014 topic-to-agent mappings`,
43189
43191
  `<code>/permissions [agent]</code> \u2014 show agent permissions`,
43190
43192
  `<code>/grant &lt;tool&gt;</code> \u2014 grant a tool permission`,
@@ -44527,6 +44529,7 @@ var INJECT_COMMANDS = new Map([
44527
44529
  ["/hooks", { description: "List configured hooks", expectsOutput: true }],
44528
44530
  ["/memory", { description: "Open memory picker", expectsOutput: true }],
44529
44531
  ["/model", { description: "Open model picker", expectsOutput: true }],
44532
+ ["/effort", { description: "Set reasoning effort", expectsOutput: true }],
44530
44533
  [
44531
44534
  "/clear",
44532
44535
  { description: "Clear session screen", expectsOutput: false }
@@ -44784,6 +44787,7 @@ var INJECT_COMMANDS2 = new Map([
44784
44787
  ["/hooks", { description: "List configured hooks", expectsOutput: true }],
44785
44788
  ["/memory", { description: "Open memory picker", expectsOutput: true }],
44786
44789
  ["/model", { description: "Open model picker", expectsOutput: true }],
44790
+ ["/effort", { description: "Set reasoning effort", expectsOutput: true }],
44787
44791
  [
44788
44792
  "/clear",
44789
44793
  { description: "Clear session screen", expectsOutput: false }
@@ -45392,6 +45396,161 @@ function extractConfirmation(pane) {
45392
45396
  return null;
45393
45397
  }
45394
45398
 
45399
+ // gateway/effort-command.ts
45400
+ var EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
45401
+ function isValidEffortArg(arg) {
45402
+ return EFFORT_LEVELS.includes(arg.toLowerCase());
45403
+ }
45404
+ function parseEffortCommand(text) {
45405
+ const m = text.match(/^\/effort(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/);
45406
+ if (!m)
45407
+ return null;
45408
+ const rest = (m[1] ?? "").trim();
45409
+ if (rest.length === 0)
45410
+ return { kind: "show" };
45411
+ const parts = rest.split(/\s+/);
45412
+ if (parts.length > 1) {
45413
+ return { kind: "help", reason: "effort takes a single level" };
45414
+ }
45415
+ const arg = parts[0];
45416
+ if (arg.toLowerCase() === "help")
45417
+ return { kind: "help" };
45418
+ if (!isValidEffortArg(arg)) {
45419
+ return { kind: "help", reason: `not a valid effort level: ${arg}` };
45420
+ }
45421
+ return { kind: "set", level: arg.toLowerCase() };
45422
+ }
45423
+ var PERSIST_NOTE2 = "<i>Session-only \u2014 reverts to the configured default on restart. To change the default, set <code>thinking_effort:</code> in switchroom.yaml and restart.</i>";
45424
+ var LEVELS_INLINE = EFFORT_LEVELS.map((l) => `<code>${l}</code>`).join(" \u00b7 ");
45425
+ function helpText3(deps, reason) {
45426
+ const lines = [];
45427
+ if (reason)
45428
+ lines.push(`\u26a0\ufe0f ${deps.escapeHtml(reason)}`);
45429
+ lines.push("<b>/effort</b> \u2014 show or switch the reasoning effort (faster\u2192smarter)", "<code>/effort</code> \u2014 show the configured effort + a tap menu", `<code>/effort &lt;level&gt;</code> \u2014 switch the live session (${LEVELS_INLINE})`, PERSIST_NOTE2);
45430
+ return { text: lines.join(`
45431
+ `), html: true };
45432
+ }
45433
+ async function handleEffortCommand(parsed, deps) {
45434
+ if (parsed.kind === "help")
45435
+ return helpText3(deps, parsed.reason);
45436
+ if (parsed.kind === "show") {
45437
+ const configured = deps.getConfiguredEffort();
45438
+ const shown = configured && configured.length > 0 ? configured : "low";
45439
+ return {
45440
+ text: [
45441
+ `<b>Effort \u2014 ${deps.escapeHtml(deps.getAgentName())}</b>`,
45442
+ `Configured default: <code>${deps.escapeHtml(shown)}</code>`,
45443
+ `Switch the live session: ${EFFORT_LEVELS.map((l) => `<code>/effort ${l}</code>`).join(" \u00b7 ")}`,
45444
+ PERSIST_NOTE2
45445
+ ].join(`
45446
+ `),
45447
+ html: true
45448
+ };
45449
+ }
45450
+ if (!isValidEffortArg(parsed.level)) {
45451
+ return helpText3(deps, `not a valid effort level: ${parsed.level}`);
45452
+ }
45453
+ const verbHtml = `<code>/effort ${deps.escapeHtml(parsed.level)}</code>`;
45454
+ let result;
45455
+ try {
45456
+ result = await deps.inject(deps.getAgentName(), `/effort ${parsed.level}`);
45457
+ } catch (err) {
45458
+ const msg = err instanceof Error ? err.message : String(err);
45459
+ return { text: `\u274c ${verbHtml} \u2014 inject failed: ${deps.escapeHtml(msg)}`, html: true };
45460
+ }
45461
+ if (result.outcome === "ok") {
45462
+ return {
45463
+ text: [
45464
+ `${verbHtml}`,
45465
+ deps.preBlock(result.output),
45466
+ ...result.truncated ? ["<i>truncated</i>"] : [],
45467
+ PERSIST_NOTE2
45468
+ ].join(`
45469
+ `),
45470
+ html: true
45471
+ };
45472
+ }
45473
+ if (result.outcome === "ok_no_output") {
45474
+ return {
45475
+ text: [
45476
+ `${verbHtml} \u2014 sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active effort.`,
45477
+ PERSIST_NOTE2
45478
+ ].join(`
45479
+ `),
45480
+ html: true
45481
+ };
45482
+ }
45483
+ if (result.errorCode === "session_missing") {
45484
+ return {
45485
+ text: "\u274c tmux session not found \u2014 the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.",
45486
+ html: true
45487
+ };
45488
+ }
45489
+ return {
45490
+ text: `\u274c ${verbHtml} \u2014 ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`,
45491
+ html: true
45492
+ };
45493
+ }
45494
+ var EFFORT_CALLBACK_PREFIX = "eff:";
45495
+ var EFFORT_CALLBACK_SELECT = "eff:s:";
45496
+ function effortSelectCallbackData(level) {
45497
+ return `${EFFORT_CALLBACK_SELECT}${level}`;
45498
+ }
45499
+ function menuKeyboard2(highlight) {
45500
+ return [
45501
+ EFFORT_LEVELS.map((l) => ({
45502
+ text: l === highlight ? `\u2705 ${l}` : l,
45503
+ callback_data: effortSelectCallbackData(l)
45504
+ }))
45505
+ ];
45506
+ }
45507
+ function buildEffortMenu(deps, highlight) {
45508
+ const configured = deps.getConfiguredEffort() || "low";
45509
+ const live = highlight ?? configured;
45510
+ return {
45511
+ text: [
45512
+ `<b>Effort \u2014 ${deps.escapeHtml(deps.getAgentName())}</b>`,
45513
+ `Default: <code>${deps.escapeHtml(configured)}</code> \u00b7 faster \u2192 smarter: ${LEVELS_INLINE}`,
45514
+ "Tap to switch the live session:",
45515
+ PERSIST_NOTE2
45516
+ ].join(`
45517
+ `),
45518
+ html: true,
45519
+ keyboard: menuKeyboard2(live)
45520
+ };
45521
+ }
45522
+ async function handleEffortMenuCallback(data, deps) {
45523
+ if (!data.startsWith(EFFORT_CALLBACK_SELECT)) {
45524
+ return { reply: buildEffortMenu(deps) };
45525
+ }
45526
+ const level = data.slice(EFFORT_CALLBACK_SELECT.length);
45527
+ if (!isValidEffortArg(level)) {
45528
+ return { reply: buildEffortMenu(deps) };
45529
+ }
45530
+ let banner;
45531
+ let selected;
45532
+ try {
45533
+ const result = await deps.inject(deps.getAgentName(), `/effort ${level}`);
45534
+ if (result.outcome === "ok" || result.outcome === "ok_no_output") {
45535
+ banner = `\u2705 Effort \u2192 <code>${deps.escapeHtml(level)}</code> for this session`;
45536
+ selected = level;
45537
+ } else if (result.errorCode === "session_missing") {
45538
+ banner = "\u274c tmux session not found \u2014 is the agent running under the supervisor?";
45539
+ } else {
45540
+ banner = `\u274c couldn't set effort: ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`;
45541
+ }
45542
+ } catch (err) {
45543
+ const msg = err instanceof Error ? err.message : String(err);
45544
+ banner = `\u274c inject failed: ${deps.escapeHtml(msg)}`;
45545
+ }
45546
+ const menu = buildEffortMenu(deps, selected);
45547
+ return {
45548
+ reply: { ...menu, text: `${banner}
45549
+ ${menu.text}` },
45550
+ selectedEffort: selected
45551
+ };
45552
+ }
45553
+
45395
45554
  // ../src/config/loader.ts
45396
45555
  init_dist();
45397
45556
  init_zod();
@@ -54158,11 +54317,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54158
54317
  }
54159
54318
 
54160
54319
  // ../src/build-info.ts
54161
- var VERSION = "0.15.18";
54162
- var COMMIT_SHA = "d7c044b9";
54163
- var COMMIT_DATE = "2026-06-14T08:55:07+10:00";
54164
- var LATEST_PR = null;
54165
- var COMMITS_AHEAD_OF_TAG = 3;
54320
+ var VERSION = "0.15.19";
54321
+ var COMMIT_SHA = "3f40c6f9";
54322
+ var COMMIT_DATE = "2026-06-14T00:06:47Z";
54323
+ var LATEST_PR = 2337;
54324
+ var COMMITS_AHEAD_OF_TAG = 0;
54166
54325
 
54167
54326
  // gateway/boot-version.ts
54168
54327
  function formatRelativeAgo(iso) {
@@ -62143,6 +62302,43 @@ bot.command("model", async (ctx) => {
62143
62302
  const reply = await handleModelCommand(parsed, deps);
62144
62303
  await switchroomReply(ctx, reply.text, { html: reply.html });
62145
62304
  });
62305
+ function buildEffortDeps() {
62306
+ return {
62307
+ inject: injectSlashCommand,
62308
+ getAgentName: getMyAgentName,
62309
+ getConfiguredEffort: () => {
62310
+ const data = switchroomExecJson(["agent", "list"]);
62311
+ return data?.agents?.find((a) => a.name === getMyAgentName())?.thinking_effort ?? null;
62312
+ },
62313
+ escapeHtml: escapeHtmlForTg,
62314
+ preBlock
62315
+ };
62316
+ }
62317
+ function effortMenuReplyMarkup(reply) {
62318
+ if (!reply.keyboard)
62319
+ return;
62320
+ const kb = new import_grammy9.InlineKeyboard;
62321
+ for (const row of reply.keyboard) {
62322
+ for (const btn of row)
62323
+ kb.text(btn.text, btn.callback_data);
62324
+ kb.row();
62325
+ }
62326
+ return kb;
62327
+ }
62328
+ bot.command("effort", async (ctx) => {
62329
+ if (!isAuthorizedSender(ctx))
62330
+ return;
62331
+ const text = ctx.message?.text ?? ctx.channelPost?.text ?? "";
62332
+ const parsed = parseEffortCommand(text) ?? { kind: "show" };
62333
+ const deps = buildEffortDeps();
62334
+ if (parsed.kind === "show") {
62335
+ const menu = buildEffortMenu(deps);
62336
+ await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) });
62337
+ return;
62338
+ }
62339
+ const reply = await handleEffortCommand(parsed, deps);
62340
+ await switchroomReply(ctx, reply.text, { html: reply.html });
62341
+ });
62146
62342
  bot.command("agentstart", async (ctx) => {
62147
62343
  if (!isAuthorizedSender(ctx))
62148
62344
  return;
@@ -64870,6 +65066,26 @@ bot.on("callback_query:data", async (ctx) => {
64870
65066
  await handleAuthDashboardCallback(ctx);
64871
65067
  return;
64872
65068
  }
65069
+ if (data.startsWith(EFFORT_CALLBACK_PREFIX)) {
65070
+ const access2 = loadAccess();
65071
+ const senderId2 = String(ctx.from?.id ?? "");
65072
+ if (!access2.allowFrom.includes(senderId2)) {
65073
+ await ctx.answerCallbackQuery({ text: "Not authorized." });
65074
+ return;
65075
+ }
65076
+ await ctx.answerCallbackQuery({ text: "Setting effort\u2026" }).catch(() => {});
65077
+ try {
65078
+ const outcome = await handleEffortMenuCallback(data, buildEffortDeps());
65079
+ await ctx.editMessageText(outcome.reply.text, {
65080
+ parse_mode: "HTML",
65081
+ reply_markup: effortMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] }
65082
+ }).catch(() => {});
65083
+ } catch (err) {
65084
+ process.stderr.write(`telegram gateway: effort-menu callback failed: ${err?.message ?? String(err)}
65085
+ `);
65086
+ }
65087
+ return;
65088
+ }
64873
65089
  if (data.startsWith(MODEL_CALLBACK_PREFIX)) {
64874
65090
  const access2 = loadAccess();
64875
65091
  const senderId2 = String(ctx.from?.id ?? "");
@@ -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'
@@ -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 ?? '')
@@ -0,0 +1,191 @@
1
+ /**
2
+ * `/effort` Telegram command — parser + handler + menu coverage.
3
+ *
4
+ * Guarantees mirrored from `/model`:
5
+ * 1. The argument is allowlist-gated before it's typed into the tmux
6
+ * pane — only the five levels claude accepts, nothing else.
7
+ * 2. The set path injects exactly `/effort <level>` (claude's own REPL
8
+ * verb, on the inject allowlist) and relays the captured output with
9
+ * the session-only / reverts-on-restart caveat.
10
+ * 3. The bare form renders a five-button menu; a tap injects the level.
11
+ */
12
+ import { describe, it, expect } from "vitest";
13
+ import {
14
+ parseEffortCommand,
15
+ handleEffortCommand,
16
+ isValidEffortArg,
17
+ EFFORT_LEVELS,
18
+ buildEffortMenu,
19
+ handleEffortMenuCallback,
20
+ effortSelectCallbackData,
21
+ EFFORT_CALLBACK_PREFIX,
22
+ type EffortCommandDeps,
23
+ } from "../gateway/effort-command.js";
24
+ import type { InjectResult } from "../../src/agents/inject.js";
25
+
26
+ function okResult(output: string): InjectResult {
27
+ return {
28
+ outcome: "ok",
29
+ output,
30
+ truncated: false,
31
+ command: "/effort",
32
+ meta: { description: "Set reasoning effort", expectsOutput: true },
33
+ };
34
+ }
35
+
36
+ function failedResult(errorMessage: string): InjectResult {
37
+ return {
38
+ outcome: "failed",
39
+ output: "",
40
+ truncated: false,
41
+ command: "/effort",
42
+ errorMessage,
43
+ meta: { description: "Set reasoning effort", expectsOutput: true },
44
+ };
45
+ }
46
+
47
+ function makeDeps(overrides: Partial<EffortCommandDeps> = {}) {
48
+ const calls: Array<{ agent: string; command: string }> = [];
49
+ const deps: EffortCommandDeps = {
50
+ inject: async (agent, command) => {
51
+ calls.push({ agent, command });
52
+ return okResult("Set effort level to high");
53
+ },
54
+ getAgentName: () => "carrie",
55
+ getConfiguredEffort: () => "low",
56
+ escapeHtml: (s) => s,
57
+ preBlock: (s) => `<pre>${s}</pre>`,
58
+ ...overrides,
59
+ };
60
+ return { deps, calls };
61
+ }
62
+
63
+ describe("effort-command: levels + validation", () => {
64
+ it("exposes exactly the five CLI levels in faster→smarter order", () => {
65
+ expect(EFFORT_LEVELS).toEqual(["low", "medium", "high", "xhigh", "max"]);
66
+ });
67
+
68
+ it("isValidEffortArg accepts levels case-insensitively, rejects others", () => {
69
+ for (const l of EFFORT_LEVELS) {
70
+ expect(isValidEffortArg(l)).toBe(true);
71
+ expect(isValidEffortArg(l.toUpperCase())).toBe(true);
72
+ }
73
+ for (const bad of ["", "highest", "fast", "/effort", "low high", "9"]) {
74
+ expect(isValidEffortArg(bad)).toBe(false);
75
+ }
76
+ });
77
+ });
78
+
79
+ describe("effort-command: parser", () => {
80
+ it("bare /effort → show", () => {
81
+ expect(parseEffortCommand("/effort")).toEqual({ kind: "show" });
82
+ expect(parseEffortCommand("/effort ")).toEqual({ kind: "show" });
83
+ });
84
+
85
+ it("/effort@bot suffix is tolerated", () => {
86
+ expect(parseEffortCommand("/effort@carrie_bot")).toEqual({ kind: "show" });
87
+ });
88
+
89
+ it("/effort <level> → set, normalized to lowercase", () => {
90
+ expect(parseEffortCommand("/effort high")).toEqual({ kind: "set", level: "high" });
91
+ expect(parseEffortCommand("/effort XHIGH")).toEqual({ kind: "set", level: "xhigh" });
92
+ });
93
+
94
+ it("/effort help → help", () => {
95
+ expect(parseEffortCommand("/effort help")).toEqual({ kind: "help" });
96
+ });
97
+
98
+ it("invalid level → help with a reason", () => {
99
+ const p = parseEffortCommand("/effort turbo");
100
+ expect(p?.kind).toBe("help");
101
+ expect((p as { reason?: string }).reason).toMatch(/not a valid effort level/);
102
+ });
103
+
104
+ it("more than one token → help", () => {
105
+ const p = parseEffortCommand("/effort high please");
106
+ expect(p?.kind).toBe("help");
107
+ expect((p as { reason?: string }).reason).toMatch(/single level/);
108
+ });
109
+
110
+ it("non-/effort text → null", () => {
111
+ expect(parseEffortCommand("/model opus")).toBeNull();
112
+ expect(parseEffortCommand("hello")).toBeNull();
113
+ });
114
+ });
115
+
116
+ describe("effort-command: handler", () => {
117
+ it("show renders the configured default", async () => {
118
+ const { deps } = makeDeps({ getConfiguredEffort: () => "medium" });
119
+ const r = await handleEffortCommand({ kind: "show" }, deps);
120
+ expect(r.text).toContain("medium");
121
+ expect(r.text).toMatch(/reverts to the configured default/);
122
+ });
123
+
124
+ it("show falls back to low when effort is unreadable", async () => {
125
+ const { deps } = makeDeps({ getConfiguredEffort: () => null });
126
+ const r = await handleEffortCommand({ kind: "show" }, deps);
127
+ expect(r.text).toContain("low");
128
+ });
129
+
130
+ it("set injects exactly '/effort <level>' and relays output", async () => {
131
+ const { deps, calls } = makeDeps();
132
+ const r = await handleEffortCommand({ kind: "set", level: "high" }, deps);
133
+ expect(calls).toEqual([{ agent: "carrie", command: "/effort high" }]);
134
+ expect(r.text).toContain("Set effort level to high");
135
+ expect(r.text).toMatch(/reverts to the configured default/);
136
+ });
137
+
138
+ it("set surfaces an inject failure", async () => {
139
+ const { deps } = makeDeps({ inject: async () => failedResult("pane locked") });
140
+ const r = await handleEffortCommand({ kind: "set", level: "max" }, deps);
141
+ expect(r.text).toContain("pane locked");
142
+ expect(r.text).toContain("❌");
143
+ });
144
+
145
+ it("set re-gates the level at the seam (defensive)", async () => {
146
+ const { deps, calls } = makeDeps();
147
+ // Hand-craft a parsed object that skipped the parser's gate.
148
+ const r = await handleEffortCommand({ kind: "set", level: "evil; rm -rf" as never }, deps);
149
+ expect(calls).toEqual([]); // never injected
150
+ expect(r.text).toMatch(/not a valid effort level/);
151
+ });
152
+ });
153
+
154
+ describe("effort-command: menu + callback", () => {
155
+ it("buildEffortMenu offers all five levels with the configured one checked", () => {
156
+ const { deps } = makeDeps({ getConfiguredEffort: () => "high" });
157
+ const menu = buildEffortMenu(deps);
158
+ const buttons = menu.keyboard!.flat();
159
+ expect(buttons.map((b) => b.callback_data)).toEqual(
160
+ EFFORT_LEVELS.map((l) => effortSelectCallbackData(l)),
161
+ );
162
+ const checked = buttons.find((b) => b.text.startsWith("✅"));
163
+ expect(checked?.text).toBe("✅ high");
164
+ expect(menu.keyboard![0]).toHaveLength(5);
165
+ });
166
+
167
+ it("callback eff:s:<level> injects the level and checks it in the re-render", async () => {
168
+ const { deps, calls } = makeDeps();
169
+ const out = await handleEffortMenuCallback(effortSelectCallbackData("xhigh"), deps);
170
+ expect(calls).toEqual([{ agent: "carrie", command: "/effort xhigh" }]);
171
+ expect(out.selectedEffort).toBe("xhigh");
172
+ expect(out.reply.text).toContain("Effort → ");
173
+ const checked = out.reply.keyboard!.flat().find((b) => b.text.startsWith("✅"));
174
+ expect(checked?.text).toBe("✅ xhigh");
175
+ });
176
+
177
+ it("callback with a failed inject keeps the menu and shows the error, no selection", async () => {
178
+ const { deps } = makeDeps({ inject: async () => failedResult("session_missing") });
179
+ const out = await handleEffortMenuCallback(effortSelectCallbackData("max"), deps);
180
+ expect(out.selectedEffort).toBeUndefined();
181
+ expect(out.reply.text).toContain("❌");
182
+ expect(out.reply.keyboard!.flat()).toHaveLength(5); // buttons preserved
183
+ });
184
+
185
+ it("callback ignores a malformed level", async () => {
186
+ const { deps, calls } = makeDeps();
187
+ const out = await handleEffortMenuCallback(`${EFFORT_CALLBACK_PREFIX}s:bogus`, deps);
188
+ expect(calls).toEqual([]);
189
+ expect(out.selectedEffort).toBeUndefined();
190
+ });
191
+ });
@@ -318,6 +318,10 @@ export const TELEGRAM_MENU_COMMANDS = [
318
318
  // same inject primitive as `/inject /model` but with a typed argument,
319
319
  // so it never opens the undriveable no-arg picker modal).
320
320
  { command: "model", description: "Show or switch the Claude model" },
321
+ // /effort — show or switch the reasoning effort (low→max, faster→smarter).
322
+ // Same Claude-native inject mechanism as /model; session-scoped, reverts
323
+ // to the configured `thinking_effort` default on restart.
324
+ { command: "effort", description: "Show or switch the reasoning effort" },
321
325
  { command: "doctor", description: "Health check (deps, services, MCP)" },
322
326
  { command: "usage", description: "Pro/Max plan quota (5h + 7d windows)" },
323
327
  // Vault — secrets + capability grants. /vault is a top-level command
@@ -379,6 +383,7 @@ export function switchroomHelpText(agentName: string): string {
379
383
  `<code>/auth rm [agent] &lt;slot&gt; [--force]</code> — remove a slot`,
380
384
  `<code>/model</code> — show the configured Claude model`,
381
385
  `<code>/model &lt;name&gt;</code> — switch the live session's model (opus · sonnet · haiku or a full id; until restart)`,
386
+ `<code>/effort</code> — show or switch reasoning effort (low · medium · high · xhigh · max; until restart)`,
382
387
  `<code>/topics</code> — topic-to-agent mappings`,
383
388
  `<code>/permissions [agent]</code> — show agent permissions`,
384
389
  `<code>/grant &lt;tool&gt;</code> — grant a tool permission`,