One-click handoff: asks Claude to write handoff.md, waits until it exists, runs /clear, then has the fresh chat read handoff.md and continue. /handoff, or the…

<img width="391" height="379" alt="image" src="https://github.com/user-attachments/assets/6a3cb2d9-57cb-47e2-84c0-6cfc547499c3" />
Small mods for Claude Code: a live sidebar with one-click buttons for the things you do every session, and a repo picker. Works on Windows, macOS and Linux.
| Mod | What you get |
|---|---|
| sidebar | Model badge, context-window gauge, session cost, a 5-hour / weekly usage dial, and buttons for New chat, Commit & Push and Handoff |
| commit-push | One click to stage, commit (with a Claude-written message) and push |
| handoff | One click to save a handoff.md, clear the chat, and continue from it |
| repo | Pick a GitHub repo, clone it if needed, and move the session into it |
In Claude Code:
/plugin marketplace add erte-del/claude-mods
/plugin install sidebar@claude-mods
/plugin install repo@claude-mods
/reload-plugins
Or from a terminal:
claude plugin marketplace add erte-del/claude-mods
claude plugin install sidebar@claude-mods --scope user
claude plugin install repo@claude-mods --scope user
Installing sidebar also installs commit-push and handoff, because its buttons call them. repo is separate, since it opens a picker when a session starts; skip it if you don't want that.
Start a new session or run /reload-plugins and the sidebar appears. It docks beside the transcript on terminals 110 columns or wider, and otherwise shows as a bar above the prompt.
Requirements: Claude Code and git. repo also uses the GitHub CLI (gh, signed in with gh auth login) to list your repos.
Live status, redrawn after every turn:
Buttons, top to bottom: + New chat runs /clear. ⇡ Commit & Push and ⇄ Handoff run the mods below. ↻ Reload plugins runs /reload-plugins.
Click ⇡ Commit & Push, or type /commit-push.
git add -A).fix: handle empty repo list in picker. If that fails it falls back to naming the changed files.git push -u origin <branch>.It never force-pushes. It stops and leaves it to you when the branch is behind its upstream, there are unresolved merge conflicts, a merge or rebase is in progress, or HEAD is detached. Your files are only staged when it gets as far as committing.
It works on whatever folder the session is in, so it follows repo.
Click ⇄ Handoff, or type /handoff. Use it when your context is getting full.
handoff.md in the project root: what you're doing, current state, the exact next step, decisions made, gotchas and open questions. An existing handoff.md is overwritten./clear.handoff.md and carry on from its next step.If you interrupt, the turn errors, or Claude finishes without writing the file, it stops without clearing anything. The prompt is in mods/handoff/hooks/register.tsx if you want to change what goes in the file.
Opens a picker when a session starts with no repo chosen, or type /repo.
list to see them all, then a number to pick.owner/repo, or a git URL.~/repos, ~/Documents, ~/Desktop, ~/code, ~/projects, ~/dev, ~/src, ~/GitHub and a few more) and clones the repo if it can't find one.Change these in /config (each mod's settings are listed under its name).
| Mod | Setting | Default | What it does |
|---|---|---|---|
| sidebar | budgetUsd | 5 | The session cost that fills the cost bar |
| sidebar | ascii | off | Draw bars with # and - and skip the dial, for terminals that can't show block characters |
| repo | owner | your gh login | Whose repos to list: another user or an organization |
| repo | cloneDir | ~/repos | Where missing repos are cloned |
| repo | openOnStart | on | Open the picker when a session starts with no active repo |
claude plugin marketplace update claude-mods
claude plugin uninstall sidebar@claude-mods
Each mod lives in its own folder under mods/ with its tests next to the code (claude plugin test mods/<name>).
hooks/register.tsx 104 lines1import { atom, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { HandoffRun } from '../types'
5
6export const IDLE: HandoffRun = { phase: 'idle', summary: '', path: '' }
7
8const run = atom({ plugin: 'handoff', key: 'run' } as const, IDLE)
9
10export const PROMPT = `My context is getting full. Before we stop, write a handoff.md in the project root so a new session with zero memory of this conversation can pick up exactly where we left off. If handoff.md already exists, overwrite it completely — don't append, this one replaces it. Keep it short and concrete, not a full doc. Include:
11
12What we're doing: the task/feature in one or two sentences.
13Current state: what's done, what's half-done, what's broken right now.
14Next step: the exact next thing to do, specific enough to act on immediately.
15Decisions made: anything we chose along the way that isn't obvious from the code (and why, if it matters).
16Gotchas: anything that tripped us up or that the next session needs to know to avoid repeating a mistake.
17Open questions: anything still unresolved.
18Don't pad it or restate things that are obvious from reading the code. If something's already clear from the repo, skip it.`
19
20export const continuePrompt = (path: string) =>
21 `Read ${path} — it is the handoff from the previous session, which had to be cleared because its context was full. Then continue exactly where it left off, starting with its "Next step".`
22
23// Turns that may end before ours does (a turn already running when the button was pressed).
24const MAX_MISSES = 2
25
26// The flow in flight; module-level so a /clear in the middle of it doesn't lose it.
27let pending: { since: number; paths: string[]; misses: number } | null = null
28
29const join = (dir: string, name: string) => `${dir.replace(/[\\/]+$/, '')}${dir.includes('\\') ? '\\' : '/'}${name}`
30
31async function set($: any, next: HandoffRun): Promise<void> {
32 await update($, run, () => next)
33}
34
35// The handoff.md written since `since`, if any (project root first, then cwd).
36async function written($: any, paths: string[], since: number): Promise<string | null> {
37 for (const p of paths) {
38 try {
39 const s = await $.fs.stat(p)
40 // a little slack for file-system timestamp rounding
41 if (s.kind === 'file' && s.size > 0 && s.mtimeMs >= since - 2000) return p
42 } catch {
43 // not there
44 }
45 }
46 return null
47}
48
49async function start($: any): Promise<string> {
50 if (pending) return 'handoff: already in progress, waiting for handoff.md'
51 const root: string = await $.session.root()
52 const cwd: string = await $.session.cwd()
53 const paths = [...new Set([join(root, 'handoff.md'), join(cwd, 'handoff.md')])]
54 pending = { since: await $.clock.now(), paths, misses: 0 }
55 await set($, { phase: 'writing', summary: 'Claude is writing handoff.md…', path: paths[0] as string })
56 // A command hook can't submit (the submit would wait on the turn the hook holds), so a timer does.
57 $.clock.after(1, () => void $.prompt.submit({ text: PROMPT, asUser: true }))
58 return `handoff: asked Claude to write ${paths[0]}; will /clear and continue once it exists`
59}
60
61// /clear, then hand the fresh chat the file. Runs after the turn that wrote it has ended.
62async function clearAndContinue($: any, path: string): Promise<void> {
63 try {
64 await set($, { phase: 'clearing', summary: 'handoff.md written, clearing…', path })
65 await $.command.run({ command: 'clear' })
66 await $.prompt.submit({ text: continuePrompt(path), asUser: true })
67 await set($, { phase: 'done', summary: 'New chat is continuing from handoff.md', path })
68 } catch (err: any) {
69 await set($, { phase: 'error', summary: `handoff: ${String(err?.message ?? err)}`, path })
70 }
71}
72
73export const register: Register = on => {
74 on('session.start', async ($, e, next) => {
75 await $.command.register({
76 name: 'handoff',
77 description: 'Have Claude write handoff.md, then /clear and continue from it in a fresh chat',
78 })
79 return next(e)
80 })
81
82 on('command.run', { command: 'handoff' }, async $ => ({ text: await start($) }))
83
84 on('turn.complete', async ($, e, next) => {
85 const result = await next(e)
86 const p = pending
87 if (!p) return result
88 const path = await written($, p.paths, p.since)
89 if (path) {
90 pending = null
91 // on a timer: /clear can't run inside the turn's own completion
92 $.clock.after(300, () => void clearAndContinue($, path))
93 } else if (e.reason === 'aborted' || e.reason === 'error' || e.reason === 'refusal') {
94 pending = null
95 await set($, { phase: 'error', summary: `Handoff stopped: the turn ended (${e.reason}) before handoff.md was written`, path: p.paths[0] as string })
96 } else if (++p.misses >= MAX_MISSES) {
97 pending = null
98 await set($, { phase: 'error', summary: 'Handoff stopped: Claude finished without writing handoff.md', path: p.paths[0] as string })
99 }
100 return result
101 })
102}
103
104types/index.d.ts 14 lines1export type HandoffPhase = 'idle' | 'writing' | 'clearing' | 'done' | 'error'
2
3export type HandoffRun = {
4 phase: HandoffPhase
5 summary: string // one line shown in the sidebar
6 path: string // handoff.md being waited on
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 handoff: { run: HandoffRun }
12 }
13}
14