SLOPSHOPPER

spinoff

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

newcommandstatuspromptmodel
v0.1.0no licenseupdated 2026-10-20zacharied/ClaudeDesktopMod-Spinoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spinoff
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /spinoff ⎿ spinoff: Usage: /spinoff [--detach] [--no-group] <task for the new session> ⎿ spinoff: By default the new session nests under this one; its report-back is dropped so this session stays asleep. ⎿ spinoff: --detach moves it to top level and files both sessions under a sidebar group (--no-group skips the group). ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

spinoff

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>
  • By default the new session nests under this one in the sidebar. Its report-back is dropped by a hook, so this session stays asleep. The parent shows a "hook blocked your prompt" card when that happens; that's expected.
  • --detach moves the new session to top level and files both sessions under one custom sidebar group.
  • --no-group (with --detach) skips the group.

Install

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.

Permissions

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"
    ]
  }
}

Develop

claude plugin validate .
claude plugin test .
Source 1 files
hooks/register.ts 231 lines
1import 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