SLOPSHOPPER

Handoff

Writes the end-of-session note from what the session actually did, and hands it to the next session.

newguardcommandtoastpromptmodel
v0.1.0MITupdated 2026-10-09ramankrishna/session-handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-handoff
› 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 › /handoff ⎿ session-handoff: Wrote HANDOFF.md: 2 commits, 3 files changed, 1 check (1 failing), with a model summary. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Handoff

A mod for Claude Code that writes the end-of-session note for you, from what the session actually did, and hands it to the next session.

Long work with an agent runs across many sessions, and each new one starts with no memory of the last. The usual fix is to ask the agent to summarise before you stop. That summary is the agent's own account, and it reads the same whether the tests were run or only talked about. Handoff writes the note in two parts and keeps them apart.

What the note holds

/handoff writes HANDOFF.md at the project root:

# Handoff

## Seen by the host
These lines record what the session did. They do not come from the model.

**Branch:** fix/gae
**Commits this session:** 2
- c3d4e5f Add a test
- b2c3d4e Fix the GAE off-by-one
**Uncommitted changes:** 1
-  M ppo/buffer.py
**Files Claude changed:** 2
- ppo/gae.py
- tests/test_gae.py
**Checks run:** 2, 1 failing at last run
- PASS `pytest -q` (last of 2 runs)
- FAIL `python eval.py && pytest -q`

## Written by the model
This is Claude's own account of the session. Check it against the section above.

### Done ...
### Not done ...
### Decisions ...
### Open questions ...
### Next step ...
  • Seen by the host comes from git and from a record the mod keeps while you work: every file Claude changed and every test, build or lint command it ran, with the result of the last run.
  • Written by the model is Claude's summary, asked for once when you run the command.

If the model says the tests pass and the top section shows a failing check, you can see that at a glance.

Picking up

The next time you start a conversation in that project, Handoff adds the note to the first message, so Claude begins with it. A small notice tells you it was loaded.

Claude is told to treat the top section as fact as of when it was written, and the model's section as an earlier claim to verify.

A note older than 14 days is not loaded. You get a notice saying so.

Install

/plugin install session-handoff --marketplace ramankrishna/session-handoff

Answer y to add the marketplace, then pick a scope. Needs Claude Code v2.1.287 or later.

| Command | What it does | | :- | :- | | /handoff | Writes the note, including Claude's summary | | /handoff quick | Writes the note without the summary. No model call, no tokens |

There is nothing to configure. To stop a note from being loaded, delete HANDOFF.md.

What it runs, reads and stores

  • Runs four git commands in the project: git rev-parse HEAD when the session starts, and git rev-parse --abbrev-ref HEAD, git log --oneline and git status --short when you run /handoff.
  • Calls the model once when you run /handoff without quick, to ask for the summary. The call goes through your own Claude Code session, reads the session's conversation, and costs tokens on your account. Nothing else in this mod calls a model.
  • Writes HANDOFF.md at the project root when you run the command. It only ever replaces a note it wrote itself. A HANDOFF.md that someone else wrote is never overwritten and never loaded.
  • Reads HANDOFF.md when a conversation starts, and adds it to the first message.
  • Stores nothing between sessions apart from that file. The record of files and checks lives in the session and ends with it.
  • Sends nothing anywhere else. No network calls of its own, no telemetry.

Like every mod, it runs with the same access to your machine as Claude Code itself. The full statement is in PRIVACY.md.

Limits

  • The top section is a record, not a verdict. A check that passed an hour before the note was written, with edits made since, still reads PASS. The note shows the last result of each command, not whether it still holds.
  • Only recognised commands count as checks. It knows the common test, build, lint and type-check commands. A custom script such as python eval.py counts only when it runs alongside one of those.
  • The note is read by the next session, the way a project's instruction files are. In a repository you did not write, read HANDOFF.md before you start a session there.
  • A long note is cut. The next session gets the first 8,000 characters, with a pointer to the file for the rest.
  • You have to run it. The note is written when you ask, not when the session ends. A session that ends without /handoff leaves no note.

Develop

claude plugin validate .
claude plugin test .
claude --plugin-dir .

License

MIT

Source 3 files
hooks/register.ts 232 lines
1// Handoff: the end-of-session note, written from what the session did.
2//
3// While you work, the host keeps a record of the files Claude changed and the
4// checks it ran. `/handoff` writes HANDOFF.md from that record and from git,
5// then adds Claude's own account under a separate heading. The next session in
6// the project is handed the file with its first message.
7
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import {
12  EMPTY,
13  FILE,
14  isCheck,
15  oneLine,
16  pickup,
17  QUESTION,
18  recordCheck,
19  recordFile,
20  relative,
21  render,
22  writtenAt,
23} from './handoff'
24import type { HandoffInput } from './handoff'
25
26const ledger = atom({ plugin: 'session-handoff', key: 'ledger' } as const, EMPTY)
27
28let root = ''
29
30/** One git command's output lines, or null when git refused or is not there. */
31async function git($: EngineInterface, ...argv: string[]): Promise<string[] | null> {
32  try {
33    const ran = await $.process.run(['git', ...argv], { cwd: root, timeoutMs: 10_000 })
34
35    if (ran.exitCode !== 0) return null
36
37    return ran.stdout
38      .split('\n')
39      .map(line => line.trimEnd())
40      .filter(line => line !== '')
41  } catch {
42    return null
43  }
44}
45
46/** Marks where the session starts: the time, and the commit that was checked out. */
47async function begin($: EngineInterface): Promise<void> {
48  root = await $.session.root()
49
50  if ((await read($, ledger)).startedAt > 0) return
51
52  const startedAt = await $.clock.now()
53  const head = await git($, 'rev-parse', 'HEAD')
54
55  await update($, ledger, was =>
56    was.startedAt > 0 ? was : { ...was, startedAt, startSha: head?.[0] ?? null },
57  )
58}
59
60/** Asks the model for its own account of the session, over the session's transcript. */
61async function summarize($: EngineInterface): Promise<{ summary: string | null; note: string }> {
62  try {
63    const reply = await $.model.fork({ prompt: QUESTION })
64
65    if (reply.isAnswered) return { summary: reply.text, note: '' }
66
67    switch (reply.reason) {
68      case 'nothing-to-fork':
69        return { summary: null, note: 'the session has no conversation to summarize yet.' }
70      case 'api-error':
71        return { summary: null, note: `the model call failed (${reply.error}).` }
72      case 'aborted':
73        return { summary: null, note: 'the model call was interrupted.' }
74      default:
75        return { summary: null, note: 'the model returned no text.' }
76    }
77  } catch {
78    return { summary: null, note: 'the model could not be asked.' }
79  }
80}
81
82async function write($: EngineInterface, isQuick: boolean): Promise<string> {
83  root = await $.session.root()
84
85  const path = `${root}/${FILE}`
86
87  if (await $.fs.exists(path)) {
88    let existing = ''
89
90    try {
91      existing = await $.fs.read(path)
92    } catch {
93      existing = ''
94    }
95
96    // A HANDOFF.md someone else wrote is theirs: it is never overwritten.
97    if (writtenAt(existing) === null) {
98      return `${FILE} exists and was not written by Handoff. Move or rename it, then run /handoff again.`
99    }
100  }
101
102  const now = await read($, ledger)
103  const branch = await git($, 'rev-parse', '--abbrev-ref', 'HEAD')
104  const commits =
105    now.startSha === null
106      ? null
107      : await git($, 'log', '--oneline', '--no-decorate', '-n', '50', `${now.startSha}..HEAD`)
108  const status = await git($, 'status', '--short')
109  const asked = isQuick
110    ? { summary: null, note: 'none was asked for (/handoff quick).' }
111    : await summarize($)
112
113  const input: HandoffInput = {
114    now: await $.clock.now(),
115    ledger: now,
116    branch: branch?.[0] ?? null,
117    commits: commits ?? [],
118    uncommitted: (status ?? []).filter(line => !line.endsWith(` ${FILE}`)),
119    summary: asked.summary,
120    summaryNote: asked.note,
121  }
122
123  await $.fs.write(path, render(input))
124
125  return oneLine(input)
126}
127
128export const register: Register = on => {
129  on('session.start', async ($, e, next) => {
130    await $.command.register({
131      name: 'handoff',
132      description: 'Write HANDOFF.md: what this session did, for the next one to pick up',
133      argumentHint: '[quick]',
134    })
135    await begin($)
136
137    return next(e)
138  })
139
140  // /clear, /resume and /branch reset the session's state and raise no session.start.
141  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
142    await begin($)
143
144    return next(e)
145  })
146
147  on('command.run', { command: 'handoff' }, async ($, e) => {
148    const args = e.args.trim()
149
150    if (args !== '' && args !== 'quick') {
151      return { text: 'Usage: /handoff writes the note with a model summary; /handoff quick leaves the summary out.' }
152    }
153
154    return { text: await write($, args === 'quick') }
155  })
156
157  // ------------------------------------------------------------- watching
158
159  on('tool.call', { tool: ['Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
160    const ran = await next(e)
161
162    if (ran.deny !== undefined || ran.isError === true) return ran
163
164    const path = relative(root, 'file_path' in e ? e.file_path : e.notebook_path)
165
166    await update($, ledger, was => recordFile(was, path))
167
168    return ran
169  })
170
171  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
172    const ran = await next(e)
173
174    if (ran.deny !== undefined) return ran
175
176    const record = ran.isError === true ? undefined : ran.result
177
178    if (isCheck(e.command) && record?.backgroundTaskId === undefined) {
179      const ok = ran.isError !== true && record?.interrupted !== true
180
181      await update($, ledger, was => recordCheck(was, e.command, ok))
182    }
183
184    // Files a shell command changed count as changed, as an edit's do.
185    const changed = (record?.bashEditDiff?.files ?? []).map(file => relative(root, file.filePath))
186
187    if (changed.length > 0) {
188      await update($, ledger, was => changed.slice(0, 50).reduce(recordFile, was))
189    }
190
191    return ran
192  })
193
194  // ------------------------------------------------------------ picking up
195
196  // A conversation's first message carries the last handoff, when there is a fresh one.
197  on('prompt.context', async ($, e, next) => {
198    const below = await next(e)
199
200    root = await $.session.root()
201
202    const path = `${root}/${FILE}`
203
204    if (!(await $.fs.exists(path))) return below
205
206    let text = ''
207
208    try {
209      text = await $.fs.read(path)
210    } catch {
211      return below
212    }
213
214    const found = pickup(text, await $.clock.now())
215
216    if ('skip' in found) {
217      if (found.skip !== '') $.ui.toast(found.skip)
218
219      return below
220    }
221
222    $.ui.toast(
223      `Loaded ${FILE} from ${found.days === 0 ? 'today' : found.days === 1 ? 'yesterday' : `${found.days} days ago`}.`,
224    )
225
226    return {
227      ...below,
228      blocks: [...below.blocks.filter(block => block.name !== 'handoff'), { name: 'handoff', text: found.text }],
229    }
230  })
231}
232
hooks/handoff.ts 225 lines
1// What a handoff holds and how it reads, with no engine in it: every function
2// here is pure, so the tests and the hooks agree.
3
4import type { Check, Ledger } from '../types'
5
6export const FILE = 'HANDOFF.md'
7
8/** How old a handoff may be and still be offered to a new session. */
9export const FRESH_DAYS = 14
10
11/** The most of a handoff a new session is handed. */
12export const MAX_CONTEXT = 8000
13
14export const EMPTY: Ledger = { startedAt: 0, startSha: null, files: [], checks: [] }
15
16// --------------------------------------------------------------- watching
17
18const TESTLIKE = new RegExp(
19  [
20    String.raw`\bpytest\b`,
21    String.raw`\bpy\.test\b`,
22    String.raw`\bpython3?\s+-m\s+(pytest|unittest)\b`,
23    String.raw`\b(tox|nox)\b`,
24    String.raw`\b(npm|pnpm|yarn|bun)\s+(run\s+)?(test|lint|typecheck|check|build)\b`,
25    String.raw`\bnpx\s+(jest|vitest|tsc|eslint|playwright)\b`,
26    String.raw`\b(jest|vitest|mocha)\b`,
27    String.raw`\bcargo\s+(test|check|clippy|build)\b`,
28    String.raw`\bgo\s+(test|vet|build)\b`,
29    String.raw`\bmake(\s+[\w-]+)?\s*$`,
30    String.raw`(\bmvn|\bgradle|\./gradlew)\s+(test|verify|check|build)\b`,
31    String.raw`\btsc\b`,
32    String.raw`\b(ruff|mypy|eslint|flake8)\b`,
33    String.raw`\b(rspec|phpunit|ctest)\b`,
34    String.raw`\b(dotnet|swift)\s+(test|build)\b`,
35    String.raw`\bclaude\s+plugin\s+(test|validate)\b`,
36  ].join('|'),
37)
38
39const NOT_A_RUN =
40  /^\s*(cat|grep|rg|ls|echo|which|head|tail|sed|awk|find|cd|export|git)\b|\b(install|uninstall|add|remove)\b/
41
42export const squash = (text: string): string => text.replace(/\s+/g, ' ').trim()
43
44/** True when some part of the command runs tests, a build, a linter or a type check. */
45export function isCheck(command: string): boolean {
46  return command
47    .split(/&&|\|\||[;|\n]/)
48    .some(part => TESTLIKE.test(part) && !NOT_A_RUN.test(part))
49}
50
51const JUNK =
52  /(^|\/)(\.git|node_modules|__pycache__|\.pytest_cache|\.mypy_cache|\.ruff_cache|\.venv|venv|target|dist|build|coverage)\//
53
54export function relative(root: string, path: string): string {
55  const base = root.endsWith('/') ? root : `${root}/`
56
57  return root !== '' && path.startsWith(base) ? path.slice(base.length) : path
58}
59
60/** Adds a file Claude changed; caches, builds and the handoff itself are left out. */
61export function recordFile(ledger: Ledger, path: string): Ledger {
62  if (path === '' || path === FILE || JUNK.test(path) || ledger.files.includes(path)) return ledger
63
64  return { ...ledger, files: [...ledger.files, path].slice(-300) }
65}
66
67/** Adds a run of a check; a command run again keeps one row with its latest result. */
68export function recordCheck(ledger: Ledger, command: string, ok: boolean): Ledger {
69  const flat = squash(command).slice(0, 200)
70  const was = ledger.checks.find(check => check.command === flat)
71  const now: Check = { command: flat, ok, runs: (was?.runs ?? 0) + 1 }
72
73  return {
74    ...ledger,
75    checks: [...ledger.checks.filter(check => check.command !== flat), now].slice(-60),
76  }
77}
78
79// ---------------------------------------------------------------- writing
80
81const two = (value: number): string => String(value).padStart(2, '0')
82
83/** A moment as the person's clock shows it: `2026-10-09 18:40`. */
84export function stamp(at: number): string {
85  const date = new Date(at)
86
87  return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}`
88}
89
90const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`
91
92const listed = (items: readonly string[], most: number): string[] => [
93  ...items.slice(0, most).map(item => `- ${item}`),
94  ...(items.length > most ? [`- and ${items.length - most} more`] : []),
95]
96
97export type HandoffInput = {
98  now: number
99  ledger: Ledger
100  /** The branch checked out, or null outside a git repository. */
101  branch: string | null
102  /** This session's commits, newest first, as `sha subject`. */
103  commits: readonly string[]
104  /** `git status --short` lines. */
105  uncommitted: readonly string[]
106  /** The model's own account, or null when none was asked for or none came. */
107  summary: string | null
108  /** Why there is no summary, when there is none. */
109  summaryNote: string
110}
111
112/** What the model is asked when the handoff wants its account of the session. */
113export const QUESTION = [
114  'Write a handoff note for whoever continues this work in a new session with no memory of this one.',
115  'Use exactly these five headings, each as a level-three markdown heading: Done, Not done, Decisions, Open questions, Next step.',
116  'Under each, at most five bullets. Make each one specific: name the file, the command or the number.',
117  'Say plainly which results you did not verify. Do not claim a test passes unless you ran it in this session.',
118  'Reply with the note only. Do not call any tool.',
119].join(' ')
120
121/** The handoff document: what the host saw first, the model's account after. */
122export function render(input: HandoffInput): string {
123  const { ledger } = input
124  const failing = ledger.checks.filter(check => !check.ok)
125  const lines = [
126    '# Handoff',
127    '',
128    `<!-- handoff written ${new Date(input.now).toISOString()} -->`,
129    '',
130    `Written ${stamp(input.now)} by Handoff, a Claude Code mod.${ledger.startedAt > 0 ? ` The session began ${stamp(ledger.startedAt)}.` : ''}`,
131    '',
132    '## Seen by the host',
133    '',
134    'These lines record what the session did. They do not come from the model.',
135    '',
136  ]
137
138  if (input.branch === null) {
139    lines.push('**Git:** not a git repository, or git could not be read.', '')
140  } else {
141    lines.push(`**Branch:** ${input.branch}`, '')
142    lines.push(`**Commits this session:** ${input.commits.length}`, ...listed(input.commits, 20), '')
143    lines.push(`**Uncommitted changes:** ${input.uncommitted.length}`, ...listed(input.uncommitted, 30), '')
144  }
145
146  lines.push(`**Files Claude changed:** ${ledger.files.length}`, ...listed(ledger.files, 40), '')
147  lines.push(
148    `**Checks run:** ${ledger.checks.length}${failing.length > 0 ? `, ${failing.length} failing at last run` : ''}`,
149    ...listed(
150      ledger.checks.map(
151        check => `${check.ok ? 'PASS' : 'FAIL'} \`${check.command}\`${check.runs > 1 ? ` (last of ${plural(check.runs, 'run')})` : ''}`,
152      ),
153      30,
154    ),
155    '',
156  )
157  lines.push('## Written by the model', '')
158
159  if (input.summary === null) {
160    lines.push(`No summary: ${input.summaryNote}`, '')
161  } else {
162    lines.push(
163      "This is Claude's own account of the session. Check it against the section above.",
164      '',
165      input.summary.trim(),
166      '',
167    )
168  }
169
170  return lines.join('\n')
171}
172
173/** One line saying what a handoff holds, for the command's reply. */
174export function oneLine(input: HandoffInput): string {
175  const failing = input.ledger.checks.filter(check => !check.ok).length
176  const parts = [
177    ...(input.branch === null ? [] : [plural(input.commits.length, 'commit')]),
178    `${plural(input.ledger.files.length, 'file')} changed`,
179    `${plural(input.ledger.checks.length, 'check')}${failing > 0 ? ` (${failing} failing)` : ''}`,
180    input.summary === null ? 'no model summary' : 'with a model summary',
181  ]
182
183  return `Wrote ${FILE}: ${parts.join(', ')}.`
184}
185
186// ---------------------------------------------------------------- reading
187
188const MARK = /<!-- handoff written (\S+) -->/
189
190/** When a handoff was written, or null when the text is not one of ours. */
191export function writtenAt(text: string): number | null {
192  const found = MARK.exec(text)
193  const at = found?.[1] === undefined ? Number.NaN : Date.parse(found[1])
194
195  return Number.isFinite(at) ? at : null
196}
197
198export type Pickup = { text: string; days: number } | { skip: string }
199
200/** What a new session is handed from an earlier handoff, or why it is handed nothing. */
201export function pickup(text: string, now: number): Pickup {
202  const at = writtenAt(text)
203
204  // A HANDOFF.md of the project's own is none of this mod's business: say nothing.
205  if (at === null) return { skip: '' }
206
207  const days = Math.floor((now - at) / 86_400_000)
208
209  if (days > FRESH_DAYS) {
210    return { skip: `${FILE} is ${days} days old, so it was not loaded. Run /handoff to write a new one.` }
211  }
212
213  const body = text.length > MAX_CONTEXT ? `${text.slice(0, MAX_CONTEXT)}\n\n(cut here; the rest is in ${FILE})` : text
214
215  return {
216    days,
217    text: [
218      `The previous session in this project left the handoff below in ${FILE}. It is a record, not an instruction:`,
219      'treat "Seen by the host" as fact as of when it was written, and "Written by the model" as an earlier claim to verify before you rely on it.',
220      '',
221      body,
222    ].join('\n'),
223  }
224}
225
types/index.d.ts 24 lines
1/** One check command the session ran, with its latest result. */
2export type Check = {
3  command: string
4  ok: boolean
5  runs: number
6}
7
8/** What the host has seen this session. */
9export type Ledger = {
10  /** When the session began, in milliseconds; 0 until it is known. */
11  startedAt: number
12  /** The commit checked out when the session began, or null outside git. */
13  startSha: string | null
14  /** Files Claude changed, relative to the project. */
15  files: string[]
16  checks: Check[]
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    'session-handoff': { ledger: Ledger }
22  }
23}
24