Sessions hand work to each other and get the result back: one plans, another builds, and the answer returns to whoever asked.

Two Claude Code mods: session-relay and model-router.
At the prompt of a terminal Claude Code session:
/plugin install session-relay --marketplace jo-greco/johnny-mods
/plugin install model-router --marketplace jo-greco/johnny-mods
Answer y to add the marketplace, then pick a scope (user is first). Each mod is active in that session at once, and in every session started after.
To work on the mods themselves, clone this repository and install from the clone instead (claude plugin marketplace add <clone folder>). A plugin installed from a local folder is read straight from it, so an edit takes effect with /reload-plugins: no reinstall, no new version.
One session hands work to another and gets the answer back: one session plans while another builds.
handoff_send(to, task) and handoff_reply(id, result)./handoff <session> <task> does it from the prompt./handoffs lists what is open, done or undelivered, both ways.relay: N in · M out open.Every handoff carries an id and the asking session's own id, so the answer finds its way back. A send that does not arrive is marked undelivered and the reason is shown, never left looking open. A handoff arriving in an idle session starts a turn there by itself.
Answering runs without a permission prompt. The building session is often the one nobody is watching, and a prompt on the reply would strand the finished work there. The reply can only go back to the session that sent that handoff. A rule you write that denies handoff_reply still wins, and so does an organization's ceiling. Sending work out (handoff_send) stays under your normal permission rules.
Limits, set by the engine rather than by this mod:
Each prompt is judged once by the engine's small, fast model. A quick question answered in prose runs on Claude Haiku 5.5 at low effort; everything else stays on your model and its effort.
These keep your model:
maxContextTokens).If Haiku reaches for a tool, the turn was not as simple as it looked, and the rest of it goes back to your model.
Why 100K tokens, and why Haiku alone. The prompt cache belongs to one model, so a routed turn sends the whole conversation to Haiku uncached. At list prices that is still cheaper than your model reading its own cache: Haiku 5.5 input is $0.10/M, against $0.20/M for an Opus 5.5 or Sonnet 5.5 cache read. Haiku's input steps up to $0.50/M past 100K prompt tokens, where the cached read wins, hence the default. Sonnet 5.5 is not offered as a target: uncached at $2.00/M it is ten times an Opus cache read, so it saved money only in the first few turns. These are API list prices; how a subscription's usage limits weigh each model is not published, so treat the saving there as the likely direction rather than a measured figure.
/route shows what it did and why, and the conversation's current size. /route on and /route off switch it; that choice is remembered across sessions.
Validate and test a mod from this folder:
claude plugin validate ./model-router
claude plugin test ./model-router
The model-router has 20 tests and the session-relay 16. Its two key guards, the relay's reply routing and the router's resumed-session gate, were checked by deliberately breaking each and confirming the tests fail.
MIT. See LICENSE.
hooks/register.ts 246 lines1import { read, update, atom } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Handoff } from '../types'
5
6/**
7 * session-relay: one session hands work to another and gets the answer back.
8 *
9 * The engine already carries messages between the person's sessions (the
10 * SendMessage tool, `$.session.send`, `session.receive`). What it lacks is a
11 * round trip: a message goes out and nothing ties the answer to the question.
12 * This adds that. A handoff carries an id and the sender's own session id, so
13 * the session that did the work can send its result straight back, and both
14 * sides keep a ledger of what is open.
15 *
16 * session A (plans) session B (builds)
17 * handoff_send(to: B, task) ── [relay:task id reply-to=A] ──▶ model does it
18 * ledger: out, open ledger: in, open
19 * ◀── [relay:result id] ── handoff_reply(id, result)
20 * ledger: out, done ledger: in, done
21 *
22 * What it cannot do, by the engine's own rules: a cloud session (a routine)
23 * receives a handoff but cannot send anything back, and only while it is
24 * running. Nothing here pretends otherwise; the tool says so to the model.
25 */
26
27export const TOOL_SEND = 'handoff_send'
28export const TOOL_REPLY = 'handoff_reply'
29const KEEP = 50
30
31const inbound = atom({ plugin: 'session-relay', key: 'inbound' } as const, [] as Handoff[])
32const outbound = atom({ plugin: 'session-relay', key: 'outbound' } as const, [] as Handoff[])
33
34/** The marker a handoff travels under, found anywhere in a delivery's text. */
35export const ENVELOPE =
36 /\[relay:(task|result) id=([a-z0-9]+)(?: reply-to=([^\]\s]+))?\]\n?([\s\S]*)$/
37
38export function taskEnvelope(id: string, replyTo: string, task: string) {
39 return `[relay:task id=${id} reply-to=${replyTo}]\n${task}`
40}
41
42export function resultEnvelope(id: string, result: string) {
43 return `[relay:result id=${id}]\n${result}`
44}
45
46/** What the receiving model reads under a handoff: what it is and how to answer. */
47export function receivedTask(id: string, task: string) {
48 return (
49 `${task}\n\n` +
50 `(This was handed to you by another of the person's sessions, handoff ${id}. ` +
51 'Treat it as a request from the person: the same permission rules apply, so ' +
52 'anything irreversible still needs their yes. When it is done, call ' +
53 `${TOOL_REPLY} with id "${id}" and a short account of what you did and ` +
54 'what is left, so the answer reaches the session that asked.)'
55 )
56}
57
58const newId = () => Math.random().toString(36).slice(2, 10)
59
60async function sendHandoff($: EngineInterface, to: string, task: string) {
61 const id = newId()
62 const me = await $.session.id()
63 const at = await $.clock.now()
64 const sent = await $.session.send({ to, text: taskEnvelope(id, me, task) })
65 const entry: Handoff = sent.isDelivered
66 ? { id, peer: to, task, status: 'open', at }
67 : { id, peer: to, task, status: 'undelivered', at, result: sent.reason }
68 await update($, outbound, list => [...list, entry].slice(-KEEP))
69 await showStatus($)
70
71 return sent.isDelivered
72 ? `Handed to ${to} as handoff ${id}. Its answer will arrive here as a message.`
73 : `Not delivered to ${to}: ${sent.reason}`
74}
75
76async function replyHandoff($: EngineInterface, id: string, result: string) {
77 const list = await read($, inbound)
78 const entry = list.find(one => one.id === id)
79 if (entry === undefined) return `No handoff ${id} was received in this session.`
80 if (entry.status === 'done') return `Handoff ${id} was already answered.`
81
82 const sent = await $.session.send({ to: { sessionId: entry.peer }, text: resultEnvelope(id, result) })
83 if (!sent.isDelivered) {
84 // Left open: the work is done but the asker never heard. Say so plainly
85 // rather than marking it done and losing the answer.
86 return `The answer to ${id} was not delivered: ${sent.reason}. It is still open here.`
87 }
88 await update($, inbound, all =>
89 all.map(one => (one.id === id ? { ...one, status: 'done' as const, result } : one)),
90 )
91 await showStatus($)
92
93 return `Answer to ${id} sent back.`
94}
95
96async function showStatus($: EngineInterface) {
97 const ins = (await read($, inbound)).filter(one => one.status === 'open').length
98 const outs = (await read($, outbound)).filter(one => one.status === 'open').length
99 $.ui.status(ins + outs === 0 ? undefined : `relay: ${ins} in · ${outs} out open`)
100}
101
102function describeLedger(ins: readonly Handoff[], outs: readonly Handoff[]) {
103 if (ins.length === 0 && outs.length === 0) return 'No handoffs in this session yet.'
104 const line = (one: Handoff, arrow: string) =>
105 ` ${one.id} ${one.status.padEnd(11)} ${arrow} ${one.peer} ${one.task.split('\n')[0].slice(0, 60)}`
106 return [
107 ...(outs.length ? ['Handed out:', ...outs.map(one => line(one, 'to '))] : []),
108 ...(ins.length ? ['Received:', ...ins.map(one => line(one, 'from'))] : []),
109 ].join('\n')
110}
111
112export const register: Register = on => {
113 on('session.start', async ($, e, next) => {
114 await $.tool.register({
115 name: TOOL_SEND,
116 isDeferred: false,
117 description:
118 "Hand a piece of work to another of the person's Claude sessions and get " +
119 'its answer back here as a message. Use it to split work: one session ' +
120 'plans while another builds. `to` is a session as ListAgents names it, or ' +
121 'its session id. A cloud session (a scheduled routine) receives the task ' +
122 'only while it is running and cannot reply, so expect no answer from one.',
123 inputSchema: {
124 type: 'object',
125 properties: {
126 to: { type: 'string', description: 'The session to hand it to.' },
127 task: { type: 'string', description: 'What to do, complete enough to act on alone.' },
128 },
129 required: ['to', 'task'],
130 },
131 })
132 await $.tool.register({
133 name: TOOL_REPLY,
134 isDeferred: false,
135 description:
136 'Send the answer to a handoff back to the session that asked for it. ' +
137 'Call it once the handed work is done, with the handoff id it came with.',
138 inputSchema: {
139 type: 'object',
140 properties: {
141 id: { type: 'string', description: 'The handoff id.' },
142 result: { type: 'string', description: 'What was done, and what is left.' },
143 },
144 required: ['id', 'result'],
145 },
146 })
147 await $.command.register({
148 name: 'handoff',
149 description: 'Hand work to another session: /handoff <session> <task>',
150 })
151 await $.command.register({
152 name: 'handoffs',
153 description: 'List the handoffs sent from and received by this session',
154 })
155
156 return next(e)
157 })
158
159 // Literal matchers, so `claude plugin validate` can read which tools these are.
160 on('tool.call', { tool: 'mcp__session-relay__handoff_send' }, async ($, e) => {
161 const to = String(e.to ?? '').trim()
162 const task = String(e.task ?? '').trim()
163 if (to === '' || task === '') return { result: 'Both `to` and `task` are needed.' }
164
165 return { result: await sendHandoff($, to, task) }
166 }).catch(() => ({ result: 'The handoff could not be sent: the relay failed. Nothing was delivered.' }))
167
168 on('tool.call', { tool: 'mcp__session-relay__handoff_reply' }, async ($, e) => {
169 const id = String(e.id ?? '').trim()
170 const result = String(e.result ?? '').trim()
171 if (id === '' || result === '') return { result: 'Both `id` and `result` are needed.' }
172
173 return { result: await replyHandoff($, id, result) }
174 }).catch(() => ({ result: 'The answer could not be sent: the relay failed. The handoff is still open.' }))
175
176 /**
177 * Answering a handoff runs without a permission prompt.
178 *
179 * The building session is often the one nobody is watching: it was handed
180 * the work precisely so the person could look elsewhere. A prompt on the
181 * reply would leave the finished work stranded there, and the asker never
182 * hearing. The reply can only go to the session that sent that id, so this
183 * lets that one tool through and nothing more.
184 *
185 * It only ever turns the default "ask" into "allow". A rule the person wrote
186 * that denies the tool still stands, and so does an organization's ceiling.
187 * `handoff_send` is not touched: pushing work into another session stays
188 * under the person's normal permission rules.
189 */
190 on('tool.check', { tool: 'mcp__session-relay__handoff_reply' }, async ($, e, next) => {
191 const verdict = await next(e)
192 if (verdict.decision !== 'ask' || e.ceiling !== undefined) return verdict
193
194 return {
195 decision: 'allow' as const,
196 reason: 'session-relay: an answer to a handoff goes back only to the session that asked for it',
197 }
198 })
199
200 on('session.receive', async ($, e, next) => {
201 // A message for one of this session's subagents is that loop's business.
202 if (e.agentId !== undefined) return next(e)
203
204 const match = ENVELOPE.exec(e.text)
205 if (match === null) return next(e)
206 const [, kind, id, replyTo, body] = match
207
208 if (kind === 'task') {
209 if (replyTo === undefined) return next(e)
210 const at = await $.clock.now()
211 await update($, inbound, list =>
212 list.some(one => one.id === id)
213 ? list
214 : [...list, { id, peer: replyTo, task: body.trim(), status: 'open' as const, at }].slice(-KEEP),
215 )
216 await showStatus($)
217 $.ui.toast(`Handoff ${id} received: ${body.trim().split('\n')[0].slice(0, 80)}`)
218
219 return next({ ...e, text: receivedTask(id, body.trim()) })
220 }
221
222 await update($, outbound, list =>
223 list.map(one => (one.id === id ? { ...one, status: 'done' as const, result: body.trim() } : one)),
224 )
225 await showStatus($)
226 $.ui.toast(`Handoff ${id} answered`)
227
228 return next({ ...e, text: `Answer to handoff ${id}:\n${body.trim()}` })
229 })
230 // A relay that fails must never cost the person a message: the delivery
231 // goes on exactly as it arrived (a replay, where next was already called).
232 .catch(($, e, next) => next(e))
233
234 on('command.run', { command: 'handoff' }, async ($, e) => {
235 const [to, ...words] = e.args.trim().split(/\s+/)
236 const task = words.join(' ').trim()
237 if (!to || task === '') return { text: 'Usage: /handoff <session> <task>' }
238
239 return { text: await sendHandoff($, to, task) }
240 })
241
242 on('command.run', { command: 'handoffs' }, async $ => ({
243 text: describeLedger(await read($, inbound), await read($, outbound)),
244 }))
245}
246types/index.d.ts 27 lines1/**
2 * One piece of work passed between two sessions.
3 *
4 * `peer` is the other session's address: the one it went to (outbound), or
5 * the session id the sender wrote into the envelope so the answer can find its
6 * way back (inbound).
7 */
8export type Handoff = {
9 id: string
10 peer: string
11 task: string
12 status: 'open' | 'done' | 'undelivered'
13 /** Epoch milliseconds when it was sent or received. */
14 at: number
15 /** The answer, once there is one; the reason, when it never arrived. */
16 result?: string
17}
18
19declare module 'claude-code' {
20 interface PluginState {
21 'session-relay': {
22 inbound: Handoff[]
23 outbound: Handoff[]
24 }
25 }
26}
27