How a session works with you and with its subagents. Two session modes, each [on|off|status], answered with no model call, Claude told once at each switch and…

How a session works with you and with its subagents: it asks you until the direction is clear, stops to check with you when the work drifts, hands work to agents by model tier, and answers without changing anything when you only want answers.
Install: ak setup and pick modes, or /plugin install modes@agentic-kit. Needs Claude Code 2.1.298 or newer: an older one loads nothing from it, silently (/ask is then an unknown command; checked on 2.1.295–2.1.301). It is a Claude Mod, so it loads only with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in the env block of ~/.claude/settings.json (ak setup sets it). Was agentic-workflows until 0.7.0. To try a change in this folder: claude --plugin-dir plugins/modes (the engine refuses a hooks module whose real path lies outside the plugin folder, so not through a symlink).
| what | does | ||
|---|---|---|---|
/modes:interview | rounds of AskUserQuestion until you and Claude agree on the direction; you start it, Claude never does | ||
/modes:align [what looks off] | mid-task: no more changes until agreed; Claude plays back the goal, what it did and what was next, names the step where the work drifted, asks with AskUserQuestion, then continues on the answer; you start it | ||
| `/orchestrate [on\ | off\ | status]` | a session mode: hand parts to subagents on another model; orchestrating in the prompt footer while on |
| `/ask [on\ | off\ | status]` | a session mode: answer only, change nothing; Edit, Write and NotebookEdit are refused while on (a subagent's too); ask mode in the prompt footer while on |
A session mode is for this session only, answered by the mod with no model call, and Claude is told at each switch (below); both can be on at once. The modes are a Claude Mod: hooks/register.ts, the plugin's one hooks module, registers them through hooks/mode.ts (the switch, the notes, the chip, for every mode; each event hooked once, as the engine requires) from one ModeSpec each (hooks/orchestrate.ts, hooks/ask.ts). It loads only with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. A new mode is a ModeSpec, its entry in register.ts and its prompts/ files.
What Claude reads is in prompts/, one file per moment, read when it is sent: prompts/<moment>.<mode>.md (switch-on, switch-off, each-prompt, after-compaction; ask also before-edit, the reason an edit is refused), its frontmatter (why, from) stripped, {{rules}} filled from prompts/_rules.<mode>.md. Change the words there; the mod holds none.
_rules.ask.md names every kind of change, the shell's included: the mod refuses the file tools, but a Bash command that writes is held back by the rules alone, since telling one from a read takes more than its text.
_rules.orchestrate.md is the delegation rules: work stays in the main session unless a part is independent, worth more than the cost of starting an agent (~5k tokens), and wants another model (haiku · sonnet · opus). The rules name models, never agents: Claude picks the agent type that fits, the project's own included, and sets the model on the call.
A plugin can add text to the conversation but never take it back. So each switch is written in once, where it happens, and the history says what is in force at every point:
The same for each mode; the sizes are orchestrate's.
| moment | Claude gets | size |
|---|---|---|
/orchestrate on | the command's row, and as its hidden note "Orchestration switched ON …" + the rules | ~500 tokens, once |
| each prompt while on | one reminder line, attached to the prompt | ~50 tokens |
/orchestrate off | the command's row, and as its hidden note "… the rules earlier in this conversation no longer apply" | ~50 tokens, once |
| the first prompt after a compaction, while on | the rules again, in place of the reminder: a summary can drop them | ~500 tokens |
Before 0.3.0 the rules came on every prompt and off was never said: asked after off, a model still answered that orchestration was ON.
The state is $.state (modes.modes, one entry per mode by name, contract types/index.d.ts) for the running process, which the footer chip reads, and $.store key <mode>:<session id> (orchestrate:…, ask:…), present only while on, so claude --resume finds it; an entry older than 90 days is removed at session start.
From the repo root:
claude plugin validate plugins/modes
npx -y -p typescript@5 tsc -p plugins/modes/tsconfig.json
cd plugins/modes && CC_SYSPROMPT_FILE=/dev/null CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test .
tsc reads the API from .claude/types/claude-code.d.ts (git-ignored): copy it from the plugin-authoring skill's types/ folder after a Claude Code update.
hooks/register.ts 13 lines1// The plugin's one hooks module (hooks.json takes one, and each event once): the session modes, all
2// registered by mode.ts, and /ask's refusal of the file tools.
3import type { Register } from 'claude-code'
4
5import { ASK, refuseEdits } from './ask'
6import { modes } from './mode'
7import { ORCHESTRATE } from './orchestrate'
8
9export const register: Register = on => {
10 modes(on, [ORCHESTRATE, ASK])
11 refuseEdits(on)
12}
13hooks/ask.ts 27 lines1// /ask [on|off|status]: this session answers and changes nothing. The switch, its reminders and its chip
2// are mode.ts; the words are prompts/*.ask.md.
3//
4// The rules ask for no change of any kind. The file tools are also refused here, in every loop (a
5// subagent's too), with prompts/before-edit.ask.md as the reason: a Bash command that writes is not, since
6// telling one from a read takes more than its text.
7import type { On } from 'claude-code'
8
9import { isOn, words } from './mode'
10import type { ModeSpec } from './mode'
11
12export const ASK: ModeSpec = {
13 name: 'ask',
14 label: 'Ask mode',
15 chip: 'ask mode',
16 description: 'Answer only, change nothing, for this session: on, off or status; nothing toggles',
17}
18
19const EDITS = new Set(['Edit', 'Write', 'NotebookEdit'])
20
21export const refuseEdits = (on: On) => {
22 on('tool.call', async ($, e, next) => {
23 if (!EDITS.has(e.tool) || !(await isOn($, ASK))) return next(e)
24 return { deny: await words($, ASK.name, 'before-edit') }
25 }).catch(($, e, next) => next(e))
26}
27hooks/mode.ts 157 lines1// A session mode: /<name> [on|off|status], what Claude is told about it, and its chip. /orchestrate and
2// /ask are each a ModeSpec, registered together by modes(on, specs) from the plugin's one hooks module, register.ts.
3//
4// A plugin can add to the conversation but never take back, so each switch is written in once, where it
5// happens, and the history says what is in force at every point:
6//
7// /<name> on | off the command's own row, no model call; its hidden note is the rules (on) or
8// "they no longer apply" (off)
9// each prompt while on one reminder line, attached to the prompt
10// the prompt after a the full rules again in place of the reminder: a summary can drop them
11// compaction, while on
12// the prompt footer the mode's chip among the mode labels while on
13//
14// The words are prompts/<moment>.<name>.md, read when they are sent: the file name says when Claude reads
15// it, the frontmatter why, and {{rules}} stands for prompts/_rules.<name>.md. Nothing to edit here for them.
16//
17// The state is $.state for this process (one entry per mode, by name, in modes.modes), and $.store (key <name>:<session id>, present only while on) so
18// `claude --resume` finds it; an entry untouched for 90 days is removed at session start.
19import { atom, read, update } from 'claude-code'
20import type { EngineInterface, On } from 'claude-code'
21
22import type { ModeState } from '../types'
23
24const STALE_MS = 90 * 24 * 3600 * 1000
25
26type Saved = { at: number }
27
28export type ModeSpec = {
29 /** The command, the prompts/ suffix and the store prefix: `orchestrate` is /orchestrate. */
30 name: string
31 /** How the command's answers name it: `Orchestration` gives "Orchestration ON for this session." */
32 label: string
33 /** The prompt footer's mode label while on. */
34 chip: string
35 description: string
36}
37
38/** A prompts/ file's body: its frontmatter (why, from) is for people and never sent. */
39async function body($: EngineInterface, file: string): Promise<string> {
40 const text = await $.fs.read(`${$.plugin.root}/prompts/${file}.md`)
41 return text.replace(/^---\n[\s\S]*?\n---\n/, '').trim()
42}
43
44/** What Claude reads at MOMENT of mode NAME: prompts/<moment>.<name>.md, {{rules}} from prompts/_rules.<name>.md. */
45export async function words($: EngineInterface, name: string, moment: string): Promise<string> {
46 const text = await body($, `${moment}.${name}`)
47 return text.includes('{{rules}}') ? text.replace('{{rules}}', await body($, `_rules.${name}`)) : text
48}
49
50const MODES = atom({ plugin: 'modes', key: 'modes' } as const, {})
51
52/** Rewrites mode SPEC's entry in MODES; none is left out when it is undefined. */
53async function set($: EngineInterface, spec: ModeSpec, to: (s: ModeState | undefined) => ModeState | undefined) {
54 await update($, MODES, modes => ({ ...modes, [spec.name]: to(modes[spec.name]) }))
55}
56
57const storeKey = (spec: ModeSpec, sessionId: string) => `${spec.name}:${sessionId}`
58
59/** Mode SPEC's state in this session, loaded from the store when the held value is another session's or none. */
60async function current($: EngineInterface, spec: ModeSpec): Promise<ModeState> {
61 const sessionId = await $.session.id()
62 const held = (await read($, MODES))[spec.name]
63 if (held?.sessionId === sessionId) return held
64 const loaded: ModeState = {
65 sessionId,
66 isOn: (await $.store.get(storeKey(spec, sessionId))) !== undefined,
67 needsRules: false,
68 }
69 await set($, spec, () => loaded)
70 return loaded
71}
72
73/** Whether mode SPEC is on in this session: for a mode's own hooks beside the switch (ask.ts's refusal). */
74export async function isOn($: EngineInterface, spec: ModeSpec): Promise<boolean> {
75 return (await current($, spec)).isOn
76}
77
78async function switchTo($: EngineInterface, spec: ModeSpec, s: ModeState, turnOn: boolean) {
79 await set($, spec, () => ({ ...s, isOn: turnOn, needsRules: false }))
80 const key = storeKey(spec, s.sessionId)
81 if (turnOn) await $.store.set(key, { at: await $.clock.now() } satisfies Saved)
82 else await $.store.delete(key)
83}
84
85async function sweep($: EngineInterface, spec: ModeSpec) {
86 const now = await $.clock.now()
87 for (const key of await $.store.keys()) {
88 if (!key.startsWith(`${spec.name}:`)) continue
89 const saved = (await $.store.get(key)) as Partial<Saved> | undefined
90 if (typeof saved?.at !== 'number' || now - saved.at > STALE_MS) await $.store.delete(key)
91 }
92}
93
94const commandOf = (spec: ModeSpec) => new RegExp(`^/(modes:)?${spec.name}(\\s|$)`)
95
96/** Registers the hooks of every mode in SPECS, once each: the commands, the notes Claude reads, the chips. */
97export function modes(on: On, specs: readonly ModeSpec[]) {
98 on('session.start', async ($, e, next) => {
99 for (const spec of specs) {
100 await $.command.register({ name: spec.name, description: spec.description, argumentHint: '[on|off|status]' })
101 await sweep($, spec)
102 await current($, spec)
103 }
104 return next(e)
105 })
106
107 on('command.run', async ($, e, next) => {
108 const spec = specs.find(s => s.name === e.command)
109 if (spec === undefined) return next(e)
110 const s = await current($, spec)
111 const arg = e.args.trim().split(/\s+/)[0] ?? ''
112 if (arg === 'status') return { text: `${spec.label}: ${s.isOn ? 'ON' : 'OFF'} (this session)` }
113 if (arg !== '' && arg !== 'on' && arg !== 'off') {
114 return { text: `Unknown argument '${arg}'. Use: /${spec.name} [on|off|status]` }
115 }
116 const turnOn = arg === '' ? !s.isOn : arg === 'on'
117 if (turnOn === s.isOn) return { text: `${spec.label} is already ${turnOn ? 'ON' : 'OFF'} for this session.` }
118 const note = await words($, spec.name, turnOn ? 'switch-on' : 'switch-off')
119 await switchTo($, spec, s, turnOn)
120 return { text: `${spec.label} ${turnOn ? 'ON' : 'OFF'} for this session.`, context: [note] }
121 })
122
123 on('prompt.submit', async ($, e, next) => {
124 if (specs.some(spec => commandOf(spec).test(e.text))) return next(e)
125 const notes: string[] = []
126 for (const spec of specs) {
127 const s = await current($, spec)
128 if (!s.isOn) continue
129 notes.push(await words($, spec.name, s.needsRules ? 'after-compaction' : 'each-prompt'))
130 if (s.needsRules) await set($, spec, v => (v ? { ...v, needsRules: false } : v))
131 }
132 if (notes.length === 0) return next(e)
133 return next({ ...e, context: [...(e.context ?? []), ...notes] })
134 }).catch(($, e, next) => next(e))
135
136 on('session.compact', async ($, e, next) => {
137 const done = await next(e)
138 if (e.trigger === 'precompute' || e.agentId !== undefined || done.skip !== undefined) return done
139 for (const spec of specs) {
140 if (await isOn($, spec)) await set($, spec, v => (v ? { ...v, needsRules: true } : v))
141 }
142 return done
143 }).catch(($, e, next) => next(e))
144
145 on('session.end', async ($, e, next) => {
146 await update($, MODES, () => ({}))
147 return next(e)
148 })
149
150 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
151 const held = await read($, MODES)
152 const chips = specs.filter(spec => held[spec.name]?.isOn).map(spec => spec.chip)
153 if (chips.length === 0) return next(e)
154 return next({ ...e, props: { ...e.props, modes: [...e.props.modes, ...chips] } })
155 })
156}
157hooks/orchestrate.ts 11 lines1// /orchestrate [on|off|status]: this session's delegation mode. The switch, its reminders and its chip are
2// mode.ts; the words are prompts/*.orchestrate.md.
3import type { ModeSpec } from './mode'
4
5export const ORCHESTRATE: ModeSpec = {
6 name: 'orchestrate',
7 label: 'Orchestration',
8 chip: 'orchestrating',
9 description: 'Delegation mode for this session: on, off or status; nothing toggles',
10}
11types/index.d.ts 10 lines1/** One session mode's state: whether it is on, and whether its rules are owed again after a compaction. */
2export type ModeState = { sessionId: string; isOn: boolean; needsRules: boolean }
3
4declare module 'claude-code' {
5 interface PluginState {
6 /** Each mode's state by its name (`orchestrate`, `ask`); none while the mode has not been read this process. */
7 'modes': { modes: Record<string, ModeState | undefined> }
8 }
9}
10