/spinoff <task>: start a fresh-context session from this one, briefed by Haiku, without waking this session's model.

A Claude Code mod for the Claude desktop app's Code tab.
/spinoff <task> starts a fresh-context session from the current one. Haiku reads the tail of this session's transcript and writes the new session's brief, so this session's model never runs and its prompt cache is untouched. Handy for coming back to a long session (hundreds of thousands of tokens) and splitting off a follow-up without paying for a cache miss.
/spinoff [--detach] [--no-group] <task>
--detach moves the new session to top level and files both sessions under one custom sidebar group.--no-group (with --detach) skips the group.At the prompt of a terminal session:
/plugin install spinoff --marketplace zacharied/ClaudeDesktopMod-Spinoff
From a shell:
claude plugin install spinoff --marketplace zacharied/ClaudeDesktopMod-Spinoff
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with zacharied/ClaudeDesktopMod-Spinoff, then Add plugin.
Answer y to add the marketplace, then choose the user scope. Sessions started afterwards load it.
In auto mode the classifier can refuse the app tools the mod calls. Allow them in ~/.claude/settings.json:
{
"permissions": {
"allow": [
"mcp__ccd_session__start_session",
"mcp__ccd_session_mgmt__detach_session",
"mcp__ccd_sidebar__create_group",
"mcp__ccd_sidebar__move_sessions"
]
}
}
claude plugin validate .
claude plugin test .hooks/register.ts 231 lines1import type { Register, SessionMessage } from 'claude-code'
2
3// /spinoff [--detach] [--no-group] <task>
4//
5// Answers its own command, so the big session's model never runs (no cache
6// miss on its transcript). Haiku reads the transcript's tail and writes the
7// brief; the app's own start_session tool starts the new session.
8//
9// By default the new session stays nested under this one, as the app draws a
10// side session. A nested session reports back when its first turn ends, which
11// would wake this session's model, so a prompt.submit hook drops that wake-up
12// when it names only sessions /spinoff started. --detach moves the new session
13// to top level instead and files both under one custom sidebar group.
14
15const TAIL_CHARS = 120_000 // ~30k tokens of recent transcript for Haiku
16const PER_MESSAGE_CHARS = 4_000
17const OPENING_CHARS = 6_000
18const CHILDREN_KEY = 'children'
19const MAX_CHILDREN = 200
20const WAKE_TAG = '<side-sessions-event>'
21
22export type SpinoffArgs = { task: string; detach: boolean; group: boolean }
23
24export function parseArgs(raw: string): SpinoffArgs {
25 let detach = false
26 let group = true
27 const words: string[] = []
28 for (const word of raw.trim().split(/\s+/).filter(Boolean)) {
29 if (word === '--detach') detach = true
30 else if (word === '--no-group') group = false
31 else if (word === '--nest') continue // the default now; still accepted
32 else words.push(word)
33 }
34 return { task: words.join(' '), detach, group }
35}
36
37// A session /spinoff started nested, kept in the plugin's store so the
38// wake-up hook knows it across hot reloads and restarts.
39export type Child = { id: string; title: string }
40
41const SESSION_IDS = /local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g
42
43// The children a side-sessions wake-up is about, or undefined when it should
44// go through: not a wake-up, about no child we know, or about another session
45// too. Matches by id, else by title, since the event's wording is the app's
46// and not documented.
47export function wakeChildren(text: string, children: readonly Child[]): Child[] | undefined {
48 if (!text.includes(WAKE_TAG)) return undefined
49 const ids = [...new Set(text.match(SESSION_IDS) ?? [])]
50 if (ids.length > 0) {
51 const known = ids.map(id => children.find(c => c.id === id))
52 return known.every(c => c !== undefined) ? (known as Child[]) : undefined
53 }
54 const byTitle = children.filter(c => c.title && text.includes(c.title))
55 return byTitle.length > 0 ? byTitle : undefined
56}
57
58const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n)} […]` : s)
59
60function renderMessage(m: SessionMessage): string {
61 const lines: string[] = []
62 if (m.text.trim()) lines.push(`[${m.role}] ${clip(m.text.trim(), PER_MESSAGE_CHARS)}`)
63 for (const use of m.toolUses) {
64 const input = JSON.stringify(use.input)
65 lines.push(` → ${use.tool}(${clip(input, 300)})${use.isError ? ' [error]' : ''}`)
66 }
67 return lines.join('\n')
68}
69
70// The opening user prompt (the original goal) plus as much of the tail as fits.
71export function renderTranscript(messages: readonly SessionMessage[]): string {
72 const rendered = messages.map(renderMessage).filter(Boolean)
73 if (rendered.length === 0) return ''
74 const firstUser = messages.find(m => m.role === 'user' && m.text.trim())
75 const opening = firstUser ? `[opening request] ${clip(firstUser.text.trim(), OPENING_CHARS)}` : ''
76
77 const tail: string[] = []
78 let size = 0
79 for (const message of [...rendered].reverse()) {
80 if (size + message.length > TAIL_CHARS) break
81 tail.unshift(message)
82 size += message.length
83 }
84 const skipped = rendered.length - tail.length
85 return [opening, skipped > 0 ? `[… ${skipped} earlier messages omitted …]` : '', ...tail]
86 .filter(Boolean)
87 .join('\n\n')
88}
89
90export function parseBrief(reply: string, task: string): { title: string; background: string } {
91 const match = reply.match(/^\s*TITLE:\s*(.+)\n([\s\S]*)$/)
92 const title = clip((match?.[1] ?? task).trim(), 56).replace(/ \[…\]$/, '…')
93 const background = (match?.[2] ?? reply).replace(/^\s*-{3,}\s*\n/, '').trim()
94 return { title, background }
95}
96
97export function findSessionId(text: string): string | undefined {
98 return text.match(/local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/)?.[0]
99}
100
101const BRIEF_SYSTEM = `You write handoff briefs. A developer is starting a NEW Claude Code session with no memory of an earlier one, to do a follow-up task. From the earlier session's transcript, write what the new session needs and nothing else.
102
103Reply in exactly this form:
104TITLE: <under 50 characters naming the new task>
105<the brief>
106
107The brief, in plain markdown, under 600 words:
108- Goal: what the earlier session was working toward.
109- State: what was done and what was left, as of the transcript's end.
110- Key facts: files, commands, decisions, constraints, gotchas the new task will need. Be specific: real paths and names.
111- Open questions or loose ends relevant to the new task.
112Leave out anything unrelated to the new task. Never invent details the transcript does not contain.`
113
114const toolText = (r: unknown): string => {
115 const res = r as { text?: string; deny?: string; isError?: boolean }
116 if (res.deny) throw new Error(res.deny)
117 if (res.isError) throw new Error(res.text ?? 'tool failed')
118 return res.text ?? ''
119}
120
121export const register: Register = on => {
122 on('session.start', async ($, e, next) => {
123 await $.command.register({
124 name: 'spinoff',
125 description: 'Start a fresh-context session for a follow-up task, briefed from this one by Haiku',
126 argumentHint: '[--detach] [--no-group] <task>',
127 })
128 return next(e)
129 })
130
131 on('command.run', { command: 'spinoff' }, async ($, e) => {
132 const args = parseArgs(e.args)
133 if (!args.task) {
134 return {
135 text: 'Usage: /spinoff [--detach] [--no-group] <task for the new session>\nBy default the new session nests under this one; its report-back is dropped so this session stays asleep.\n--detach moves it to top level and files both sessions under a sidebar group (--no-group skips the group).',
136 }
137 }
138
139 const call = async (tool: string, input: Record<string, unknown> = {}) =>
140 toolText(await $.tool.call({ tool, ...input } as never))
141
142 try {
143 $.ui.status('spinoff: reading transcript…')
144 // The app's id for this session (local_…), not the CLI's transcript id.
145 const self = JSON.parse(await call('mcp__ccd_session_mgmt__get_session', { session_id: 'self' }))
146 const parentId: string = self.sessionId
147 const transcript = renderTranscript(await $.session.messages())
148 if (!transcript) return { text: 'spinoff: this session has no transcript to brief from yet.' }
149
150 $.ui.status('spinoff: Haiku is writing the brief…')
151 const reply = await $.model.complete({
152 model: 'haiku',
153 system: BRIEF_SYSTEM,
154 prompt: `New task for the new session:\n${args.task}\n\n<transcript>\n${transcript}\n</transcript>`,
155 maxTokens: 2000,
156 timeoutMs: 120_000,
157 })
158 if (!reply.isAnswered) return { text: `spinoff: Haiku gave no brief (${reply.reason}).` }
159 const { title, background } = parseBrief(reply.text, args.task)
160 const fullBackground = `${background}\n\n---\nThis brief was written by Haiku from the tail of session ${parentId}. For detail it lacks, search or export that session's transcript with the ccd_session_mgmt tools (search_session_transcripts, export_transcript) rather than guessing.`
161
162 $.ui.status('spinoff: starting session…')
163 const started = await call('mcp__ccd_session__start_session', {
164 initiation: 'user_asked',
165 context: 'fresh',
166 title,
167 prompt: args.task,
168 background: fullBackground,
169 })
170 $.ui.log(`spinoff: start_session said: ${started}`, { to: 'debug' })
171 const childId = findSessionId(started)
172 if (!childId) return { text: `spinoff: started "${title}", but could not read its id:\n${started}` }
173
174 const notes: string[] = []
175 let nested = !args.detach
176 if (args.detach) {
177 try {
178 await call('mcp__ccd_session_mgmt__detach_session', { session_id: childId })
179 } catch (err) {
180 nested = true
181 notes.push(`could not detach it (${err}); it stays nested`)
182 }
183 }
184 if (nested) {
185 const children = ((await $.store.get(CHILDREN_KEY)) ?? []) as Child[]
186 await $.store.set(CHILDREN_KEY, [...children, { id: childId, title }].slice(-MAX_CHILDREN))
187 notes.push('nested under this session; its report-back will be dropped so this session stays asleep')
188 }
189
190 if (args.detach && args.group) {
191 try {
192 let groupId: string | undefined = self.group?.id
193 let groupName: string | undefined = self.group?.name
194 if (!groupId) {
195 groupName = clip(String(self.title ?? 'Spinoffs'), 60).replace(/ \[…\]$/, '…')
196 const created = await call('mcp__ccd_sidebar__create_group', { name: groupName })
197 groupId = created.match(/cg-[0-9a-f-]+/)?.[0] ?? JSON.parse(created).id
198 }
199 const ids = self.group?.id ? [childId] : ['self', childId]
200 await call('mcp__ccd_sidebar__move_sessions', { session_ids: ids, group_id: groupId })
201 notes.push(`filed under the group "${groupName}"`)
202 } catch (err) {
203 notes.push(`could not group the sessions (${err})`)
204 }
205 }
206
207 return {
208 text: [`spinoff: started "${title}" (${childId}).`, ...notes.map(n => `- ${n}`)].join('\n'),
209 }
210 } catch (err) {
211 return { text: `spinoff failed: ${err}` }
212 } finally {
213 $.ui.status(undefined)
214 }
215 })
216
217 on('prompt.submit', async ($, e, next) => {
218 if (!e.text.includes(WAKE_TAG)) return next(e)
219 const children = ((await $.store.get(CHILDREN_KEY)) ?? []) as Child[]
220 const woken = wakeChildren(e.text, children)
221 const origin = e.origin?.kind ?? 'unknown'
222 $.ui.log(`spinoff: side-sessions wake-up, origin ${JSON.stringify(e.origin)}:\n${e.text}`, { to: 'debug' })
223 if (!woken) {
224 $.ui.log(`spinoff: let a side-sessions wake-up through (origin: ${origin}); it names a session /spinoff did not start`)
225 return next(e)
226 }
227 $.ui.log(`spinoff: dropped the wake-up from ${woken.map(c => `"${c.title}"`).join(', ')} (origin: ${origin})`)
228 return { drop: 'spinoff: report-back from a spun-off session' }
229 })
230}
231