Learn mode: one-step pacing, no unasked web search, no work on todos you haven't reached

Claude Code mods (function-hook plugins). Each folder is one plugin; .claude-plugin/marketplace.json makes this repo a marketplace.
| Mod | What it does |
|---|---|
cheap-worker | /worker spins up Claude Code workers from a picker; /workers lists, messages and closes them. Team mode lets workers find each other (ListAgents) and talk (SendMessage). |
agent-progress | /progress: animated progress bar per agent (main, subagents, workers) from their task lists. |
agent-map | /map: project file tree; red 🔨 = an agent is editing it, green ✓ = done, ready for review. |
context-bar | Context-window breakdown above the prompt. |
usage-meter | 5-hour / weekly usage in the footer. |
quick-settings | ⚙ footer button for model and effort. |
skins | /skins: colour themes (Tokyo Night, Dracula, Gruvbox, …). |
cw-pane, driver-seat-guard, evidence-band | For the driver-seat workflow; skip them elsewhere. |
/plugin marketplace add ianwilson97/claude-mods
/plugin install cheap-worker@ianwilson-mods
/plugin install agent-progress@ianwilson-mods
/plugin install agent-map@ianwilson-mods
On the machine where this folder is the live copy (loaded through CLAUDE_CODE_PLUGIN_DIRS), don't also install from the marketplace: each mod would load twice.
Set per machine in /config (or pluginConfigs.cheap-worker.options in ~/.claude/settings.json):
| Option | Values | |
|---|---|---|
provider | openrouter (default) | Any OpenRouter model; key read from $CW_OPENROUTER_API_KEY (export it in ~/.zshenv). Workers skip user settings, hooks and plugins. |
bedrock | Claude only (Sonnet / Opus / Haiku). Workers keep your user settings, so the Bedrock env there (CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_PROFILE, …) applies. No call to openrouter.ai. | |
launch | panes (default) | All workers tiled in tmux session cw: tmux new -A -s cw. |
tabs | Each worker gets a tmux session of its own and opens in a background tab: a cmux tab when Claude Code runs inside cmux, else a Ghostty tab (focus stays where you were). |
Work laptop: provider = bedrock, launch = tabs.
crewThe mod installs crew into ~/.local/bin (that folder must be on your PATH; it never overwrites a different crew).
crew # list running workers, pick one by number
crew ab12c # attach to cw-ab12c
If Ghostty can't be scripted (macOS asks once to let Claude control Ghostty; a managed Mac may refuse), the worker still runs: press CMD+T and run crew. Closing a tab only detaches; the worker keeps running until you close it from /workers.
Needs: tmux; for auto-opened tabs, cmux or Ghostty 1.3+.
/plugin lists the mods as enabled. Managed settings can block marketplaces or plugins; if so, nothing loads./worker, launch one with task say hi; a Ghostty tab opens (or the toast says CMD+T, then crew …)./workers send one a message: messaging is Claude Code's own (ListAgents / SendMessage) and works under Bedrock unless policy turns it off.hooks/register.tsx 106 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Guard } from '../types'
5
6const guard = atom({ plugin: 'driver-seat-guard', key: 'guard' } as const, {
7 isOn: false,
8 todo: null,
9 isSearchAsked: false,
10} as Guard)
11
12const TODOS = '.driver-seat/todos'
13const SEARCH_ASK = /\b(search|look (it|this|that) up|google|web|check the docs)\b/i
14
15const PACING = (todo: string | null) => `# Driver-seat learn mode (enforced by a plugin)
16The user writes the code. You tutor.
17- Give ONLY the next single step: 3-6 sentences, one tiny concrete example.
18- Do not explain later steps, write tests for later todos, or start workers.
19- If the user says they don't understand, answer simpler and shorter, not reworded.
20- Web search/fetch is blocked unless the user's latest prompt asks for it.
21${todo ? `- Active todo: ${todo}. Stay inside it until the user says to move on.` : '- No active todo set.'}`
22
23// Finds the NNN of the todo whose frontmatter says `status: doing`.
24async function doingTodo($: EngineInterface): Promise<string | null> {
25 const entries = await $.fs.list(TODOS).catch(() => [])
26 for (const entry of entries) {
27 if (entry.kind !== 'file' || !entry.name.endsWith('.md')) continue
28 const text = await $.fs.read(`${TODOS}/${entry.name}`).catch(() => '')
29 if (typeof text === 'string' && /^status:\s*doing\b/m.test(text)) {
30 return entry.name.match(/^\d+/)?.[0] ?? null
31 }
32 }
33 return null
34}
35
36// True when `path` belongs to a todo other than `active` (e.g. a red test for 003
37// while 001 is in progress). Return false to allow the write.
38export function isOffTodo(path: string, active: string): boolean {
39 // TODO(driver): your rule. See the request in chat.
40 return false
41}
42
43export const register: Register = on => {
44 on('session.start', async ($, e, next) => {
45 await $.command.register({
46 name: 'learn',
47 description: 'Learn mode: /learn [todo NNN] on, /learn off',
48 })
49 return next(e)
50 })
51
52 on('command.run', { command: 'learn' }, async ($, e) => {
53 const arg = e.args.trim()
54 if (arg === 'off') {
55 await update($, guard, g => ({ ...g, isOn: false }))
56 return { text: 'Learn mode off.' }
57 }
58 const todo = arg.match(/^\d+$/) ? arg : await doingTodo($)
59 await update($, guard, g => ({ ...g, isOn: true, todo }))
60 return { text: `Learn mode on. Active todo: ${todo ?? 'none (pass /learn NNN)'}.` }
61 })
62
63 on('prompt.submit', async ($, e, next) => {
64 await update($, guard, g => ({ ...g, isSearchAsked: SEARCH_ASK.test(e.text) }))
65 return next(e)
66 })
67
68 on('prompt.compose', async ($, e, next) => {
69 const composed = await next(e)
70 const g = await read($, guard)
71 if (!g.isOn) return composed
72 return {
73 sections: [
74 ...composed.sections,
75 { id: 'driver-seat-guard:pacing', text: PACING(g.todo), scope: 'session' as const },
76 ],
77 }
78 })
79
80 on('tool.call', { tool: ['WebSearch', 'WebFetch'] }, async ($, e, next) => {
81 const g = await read($, guard)
82 return g.isOn && !g.isSearchAsked
83 ? { deny: `driver-seat-guard: learn mode, no ${e.tool} unless the user asks for a search.` }
84 : next(e)
85 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'driver-seat-guard: guard failed.' }))
86
87 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
88 const g = await read($, guard)
89 return g.isOn && g.todo && isOffTodo(e.file_path, g.todo)
90 ? { deny: `driver-seat-guard: ${e.file_path} belongs to a later todo; active is ${g.todo}.` }
91 : next(e)
92 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'driver-seat-guard: guard failed.' }))
93
94 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
95 const g = await read($, guard)
96 if (!g.isOn || e.props.hasSurvey) return next(e)
97 const { Box, Text } = $.ui.resolve(e)
98 return (
99 <Box flexDirection="column">
100 <Text dimColor>LEARN · todo {g.todo ?? 'none'} · /learn off to exit</Text>
101 {await next(e)}
102 </Box>
103 )
104 })
105}
106types/index.d.ts 8 lines1export type Guard = { isOn: boolean; todo: string | null; isSearchAsked: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 'driver-seat-guard': { guard: Guard }
6 }
7}
8