Raises the main loop to the configured effort (default xhigh) while in plan mode, by rewriting each request; the session effort setting is never touched…

<img src="../assets/brewmods/plan-effort.gif" width="800" alt="Claude Code terminal, session effort high: Shift+Tab into plan mode shows no row, the first prompt shows a toast and a yellow plan-mode row above the prompt, and Shift+Tab out removes the row.">
<h1 align="center">Plan-mode effort</h1>
<a href="https://github.com/kochetkov-ma/claude-brewcode/stargazers"><img src="https://img.shields.io/github/stars/kochetkov-ma/claude-brewcode?style=flat&logo=github" alt="GitHub stars"></a> <img src="https://img.shields.io/badge/Claude_Code-2.1.301%2B-d97706" alt="Requires Claude Code 2.1.301+"> <img src="https://img.shields.io/badge/type-mod-8b5cf6" alt="Claude Code mod"> <img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT">
Pairs well with brewmod-placeholders. Part of the brewcode mods.
Requires Claude Code 2.1.301+: run claude update first.
claude plugin marketplace add https://github.com/kochetkov-ma/claude-brewcode && claude plugin install brewmod-plan-effort@claude-brewcode
Then run /reload-plugins.
claude update # Claude Code 2.1.301+ first
claude plugin marketplace update claude-brewcode && claude plugin update brewmod-plan-effort@claude-brewcode
claude --plugin-dir ./brewmod-plan-effort # dev mode, no install
claude update (mods use the hooks module API, early access).node on the PATH (runs the bundled command hook hooks/mode.mjs)./brewtools:plugin-update.| You do | You get |
|---|---|
Shift+Tab twice from the default mode (manual mode on → accept edits on → plan mode on), or /plan | Nothing shows at the keypress itself: Claude Code gives mods no live mode signal, so the mod learns plan mode at the first prompt or tool batch. From that prompt, requests run at xhigh, a yellow row appears above the prompt and a toast shows once per plan visit |
Shift+Tab out of plan mode | The row disappears at once (about 0.1 s), because the hint line Claude Code draws names the mode just left. The next request is already at your session effort. The status line keeps showing your session effort: the mod raises effort per request and never changes the session setting |
Shift+Tab back into plan mode, then a prompt | A second plan visit: the row and the toast show again |
| Approve the plan | The next request is back at your session effort, nothing to undo |
/plan-effort max | Plan-mode level set to max, same as the planEffort option in /config; the row updates to the new level |
| Where | Text | When |
|---|---|---|
| Toast, 5 s | brewmod-plan-effort: plan mode · effort raised to xhigh | Once per plan visit (leaving and re-entering plan mode starts a new one), only when the mod raised the effort |
| No row | Default or any other mode; before the first prompt of a plan visit; after Shift+Tab out of plan mode |
In plan mode the yellow row (theme warning colour) above the prompt and the session line of the /plan-effort status block follow the session effort:
| Session effort | Row above the prompt | session line |
|---|---|---|
Below planEffort (here high) | plan mode · effort xhigh for this mode · change: /plan-effort <level> | high -> raised to xhigh in plan mode |
At or above planEffort (here max) | plan mode · effort max, already >= xhigh | max not raised, session already >= plan effort |
| None: the model takes no effort level | plan mode · effort not raised: model takes no effort level | none not raised, model takes no effort level |
A number (here 3) | plan mode · effort not raised: 3 is not an effort level | 3 not raised, 3 is not an effort level |
| No request seen yet | no row | n/a until the next request |
Claude Code adds the brewmod-plan-effort: prefix to the toast and to the first line of command output; the mod supplies the text after it.
The row stays through turns and typing, sits above any other prompt-area UI and steps aside while a survey is open. With brewmod-placeholders loaded, it stays visible above the placeholders band in either load order. The row takes one row from the band's budget, so the band below keeps its full layout and does not overflow in fullscreen.
/plan-effort prints a status block:
status
plan effort xhigh (medium | high | xhigh | max)
session high -> raised to xhigh in plan mode
mode now plan
/plan-effort <level> set the plan-mode effort
/plan-effort show this status
plan effort shows the configured level and the allowed levels; mode now is one of:
mode now | Meaning |
|---|---|
plan (or the other mode name from the state file) | Mode recorded at the last prompt or tool batch |
unknown until the next prompt | No state file yet |
plan mode left (Shift+Tab); known at the next prompt | You left plan mode by Shift+Tab; the new mode is learned at the next prompt |
| Command | Result | |||
|---|---|---|---|---|
/plan-effort (also status) | The status block above | |||
/plan-effort help | The status block under the head help · in plan mode, main-session requests below the plan effort are sent at it; never lowered; subagents and the session effort setting are left alone | |||
| `/plan-effort <medium\ | high\ | xhigh\ | max>` | Sets planEffort and prints plan effort: <old> -> <new> plus the status rows |
/plan-effort <other word> | Prints unknown '<arg>' plus the status rows |
/config has one option, planEffort (default xhigh). In claude -p there is no /config row: the command prints plan effort: cannot set it here (no /config row, e.g. under -p) · set: /config in an interactive session, or settings.json pluginConfigs.
/effort is never written; subagents keep their own effort.planEffort (for example max) is not changed.classic.* hooks.xhigh requests, default mode 0, on Team-org and Max profiles. Full results.xhigh on all 6 plan-mode requests. Measurement and limits.hooks/mode.mjs, a bundled command hook on UserPromptSubmit and PostToolBatch, writes the main loop's permission_mode to <plugin root>/.state/mode-<session_id>.json; turn.step reads it.planEffort.Full detail: Technical details. Background: anthropics/claude-code#91444.
Docs | Project page | Source | All mods | Release notes
Author: Maksim Kochetkov | License: MIT
hooks/register.ts 143 lines1import type { Register } from 'claude-code'
2import type { PlanSeen } from '../types'
3
4const RANK = ['low', 'medium', 'high', 'xhigh', 'max'] as const
5const LEVELS = ['medium', 'high', 'xhigh', 'max'] as const
6const SESSION_ID = /^[A-Za-z0-9_-]+$/
7// Live CC 2.1.301: a Shift+Tab out of plan draws one hint 'plan mode on (shift+tab to cycle)', in plan it reads '(shift+tab to cycle)'
8const LEFT_PLAN = /^plan mode on\b/
9const NONE = 'none'
10const USAGE = '/plan-effort <level> set the plan-mode effort\n/plan-effort show this status'
11const HEADLESS = 'plan effort: cannot set it here (no /config row, e.g. under -p) · set: /config in an interactive session, or settings.json pluginConfigs'
12const HELP = 'help · in plan mode, main-session requests below the plan effort are sent at it; never lowered; subagents and the session effort setting are left alone'
13const session = { plugin: 'brewmod-plan-effort', key: 'session' } as const
14const toasted = { plugin: 'brewmod-plan-effort', key: 'toasted' } as const
15const plan = { plugin: 'brewmod-plan-effort', key: 'plan' } as const
16const rank = (v: unknown): number => RANK.findIndex((r) => r === v)
17
18type ModeFile = { mode: string; ts: number }
19
20// Pure: state-file text -> {mode, ts}; missing or corrupt input is undefined (fail open)
21const modeOf = (text: unknown): ModeFile | undefined => {
22 if (typeof text !== 'string') return undefined
23 try {
24 const j = JSON.parse(text) as { mode?: unknown; ts?: unknown } | null
25 return typeof j?.mode === 'string' ? { mode: j.mode, ts: typeof j.ts === 'number' ? j.ts : 0 } : undefined
26 } catch {
27 return undefined
28 }
29}
30
31// Pure: a left-plan hint holds until mode.mjs writes a newer file; a ts-less file (ts 0) never holds it
32const isLeftNow = (file: ModeFile | undefined, seen: PlanSeen | undefined): boolean => file !== undefined && file.ts > 0 && seen?.isLeft === true && seen.ts === file.ts
33const isPlanNow = (file: ModeFile | undefined, seen: PlanSeen | undefined): boolean => file?.mode === 'plan' && !isLeftNow(file, seen)
34
35// Pure: why a non-level session effort is never raised
36const noLevel = (s: string): string => (s === NONE ? 'model takes no effort level' : `${s} is not an effort level`)
37
38// Pure: the row above the prompt; undefined = no row
39const rowText = (isPlan: boolean, planEffort: string, sess: string | undefined): string | undefined => {
40 if (!isPlan || sess === undefined) return undefined
41 const have = rank(sess)
42 if (have < 0) return `plan mode · effort not raised: ${noLevel(sess)}`
43 return have < rank(planEffort) ? `plan mode · effort ${planEffort} for this mode · change: /plan-effort <level>` : `plan mode · effort ${sess}, already >= ${planEffort}`
44}
45
46// Pure: head line (the engine glues the plugin name to it) + aligned status rows + usage
47const block = (head: string, planEffort: string, levels: readonly string[], sess: string | undefined, mode: string): string => {
48 const have = rank(sess)
49 const s =
50 sess === undefined
51 ? 'n/a until the next request'
52 : have < 0
53 ? `${sess.padEnd(7)}not raised, ${noLevel(sess)}`
54 : `${sess.padEnd(7)}${have < rank(planEffort) ? `-> raised to ${planEffort} in plan mode` : 'not raised, session already >= plan effort'}`
55 return [head, `plan effort ${planEffort.padEnd(7)}(${levels.join(' | ')})`, `session ${s}`, `mode now ${mode}`, USAGE].join('\n')
56}
57
58export const register: Register = (on, options) => {
59 // Re-fires after every reload (a config set reloads the module): register is idempotent
60 on('session.start', async ($, e, next) => {
61 await $.command.register({ name: 'plan-effort', description: 'Show or set the plan-mode effort', argumentHint: '[status|help|medium|high|xhigh|max]' }).catch(() => undefined)
62 return next(e)
63 })
64
65 on('command.run', { command: 'plan-effort' }, async ($, e, next) => {
66 const words = e.args.trim().split(/\s+/).filter((w) => w !== '')
67 if (words[0] === 'set') words.shift()
68 const arg = words[0]?.toLowerCase()
69 const rows = await $.config.list().catch(() => [])
70 const row = rows.find((r) => r.key.endsWith('.planEffort') && r.provider.plugin.startsWith($.plugin.name))
71 const levels = row?.options ?? LEVELS
72 let target = typeof row?.value === 'string' ? row.value : String(options.planEffort)
73 const sid = await $.session.id()
74 const file = typeof sid === 'string' && SESSION_ID.test(sid) ? modeOf(await $.fs.read(`${$.plugin.root}/.state/mode-${sid}.json`).catch(() => undefined)) : undefined
75 const seen = (await $.state.get(plan).catch(() => undefined))?.value
76 const left = isLeftNow(file, seen)
77 const mode = file === undefined ? 'unknown until the next prompt' : left ? 'plan mode left (Shift+Tab); known at the next prompt' : file.mode
78 const held = (await $.state.get(session).catch(() => undefined))?.value
79 const status = (head = 'status'): string => block(head, target, levels, held, mode)
80 if (arg === undefined || arg === 'status') return { text: status() }
81 if (arg === 'help') return { text: status(HELP) }
82 if (!levels.includes(arg)) return { text: status(`unknown '${words[0]}'`) }
83 const res = row === undefined ? undefined : await $.config.set({ key: row.key, value: arg }).catch(() => undefined)
84 if (res === undefined) return { text: status(HEADLESS) }
85 if (res.deny !== undefined) return { text: `${res.deny}\n${USAGE}` }
86 const old = target
87 target = arg
88 return { text: status(`plan effort: ${old} -> ${arg}`) }
89 })
90
91 // Raise only (lower/equal, numeric, absent efforts and subagent steps untouched); mode = hooks/mode.mjs state file (works on org-managed installs too)
92 // One-shot toast per plan visit; session/toasted/plan live in $.state, so a reload neither repeats nor loses them
93 on('turn.step', async function* ($, e, next) {
94 const main = e.agentId === undefined
95 const wanted = rank(options.planEffort)
96 const have = rank(e.effort)
97 let target: (typeof RANK)[number] | undefined
98 if (main) {
99 const sid = await $.session.id()
100 // no read unless the id is path-safe (same check as mode.mjs)
101 const file = typeof sid === 'string' && SESSION_ID.test(sid) ? modeOf(await $.fs.read(`${$.plugin.root}/.state/mode-${sid}.json`).catch(() => undefined)) : undefined
102 const seen = (await $.state.get(plan).catch(() => undefined))?.value
103 const isPlan = isPlanNow(file, seen)
104 if (have >= 0 && have < wanted && isPlan) target = RANK[wanted]
105 const sess = e.effort === undefined ? NONE : String(e.effort)
106 if ((await $.state.get(session).catch(() => undefined))?.value !== sess) await $.state.set(session, sess).catch(() => undefined)
107 const isLeft = isLeftNow(file, seen)
108 // no file = not plan (fail open), so a vanished file drops the row too
109 const now = { isPlan, ts: file?.ts ?? 0, isLeft }
110 if (seen === undefined ? file !== undefined : seen.ts !== now.ts || seen.isPlan !== now.isPlan || seen.isLeft !== now.isLeft) await $.state.set(plan, now).catch(() => undefined)
111 const shown = (await $.state.get(toasted).catch(() => undefined))?.value === true
112 if (target !== undefined && !shown) {
113 $.ui.toast(`plan mode · effort raised to ${target}`, { timeoutMs: 5000 })
114 await $.state.set(toasted, true).catch(() => undefined)
115 } else if (shown && !isPlan) await $.state.set(toasted, false).catch(() => undefined)
116 }
117 return yield* next(target === undefined ? e : { ...e, effort: target })
118 })
119
120 // Shift+Tab out of plan mode: drop the row and end the toast's visit; a draw cannot write state, so a timer does, unless a step wrote since the draw
121 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
122 const r = LEFT_PLAN.test(e.props.hint) ? await $.state.get(plan).catch(() => undefined) : undefined
123 const ts = r?.value?.isPlan === true ? r.value.ts : undefined
124 if (r !== undefined && ts !== undefined) {
125 $.clock.after(0, () => void $.state.set(plan, { isPlan: false, ts, isLeft: true }, { ifVersion: r.version }).then((w) => (w.isSet ? $.state.set(toasted, false) : undefined)).catch(() => undefined))
126 }
127 return next(e)
128 })
129
130 // Persistent plan-mode row above whatever the plugins beneath draw; yields to a survey
131 // The row takes one of maxRows: hooks beneath get one fewer, so a band sized to it never scrolls under the row
132 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
133 if (e.props.hasSurvey) return next(e)
134 const seen = (await $.state.get(plan).catch(() => undefined))?.value
135 const sess = (await $.state.get(session).catch(() => undefined))?.value
136 const text = rowText(seen?.isPlan === true, String(options.planEffort), sess)
137 if (text === undefined) return next(e)
138 const below = await next({ ...e, props: { ...e.props, maxRows: Math.max(1, e.props.maxRows - 1) } })
139 const { Box, Text } = $.ui.resolve(e)
140 return Box({ flexDirection: 'column', children: [Text({ color: 'warning', wrap: 'truncate-end', children: text }), below] })
141 })
142}
143types/index.d.ts 10 lines1export type Effort = string
2// Plan mode as last decided from the state file (ts); isLeft = a Shift+Tab hint ended it since
3export type PlanSeen = { isPlan: boolean; ts: number; isLeft: boolean }
4
5declare module 'claude-code' {
6 interface PluginState {
7 'brewmod-plan-effort': { session: Effort; toasted: boolean; plan: PlanSeen }
8 }
9}
10