SLOPSHOPPER

handoff

/handoff: a handoff note written from this session (secret shapes redacted) under ~/.claude/handoffs/, the conversation cleared, the next prompt pre-filled to…

newcommandtoastmodelprocesstimer
v0.1.0MITupdated 2026-10-12Joseph-Ayoub/claude-mods/handoff
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · 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 ⎿ handoff: handoff: confirm in the dialog. The note is written to ~/.claude/handoffs/ before anything is cleared; if any ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

handoff

/handoff ends this session's conversation at a milestone without losing the thread: a handoff note is written from the conversation (goal, what is done, decisions, exact next steps, open questions, gotchas), saved under ~/.claude/handoffs/, the conversation is cleared, and the next prompt is pre-filled to continue from the note. You press Enter; nothing is sent for you.

It is one command so the flow lives in one place: mem-gauge's Hand off button (on its context tip, past 150k tokens) and the cockpit band's Hand off chip (offered at a natural break) both run /handoff.

Use it

/handoff
/handoff Phase 2: the cockpit chip

Anything after the name is the focus: one line (at most 300 characters) the note is asked to steer its next steps toward. Typed mid-turn, /handoff waits for the turn to end, so the note covers it.

Compact or hand off?

They are not the same thing.

  • Compact keeps the same session and summarizes the conversation so far in place: the model writes a summary, the old messages are replaced by it, and you keep going. Best mid-task, when there is no natural break and the recent context matters. It loses detail: exact tool outputs and file contents are gone, only what the summary keeps.
  • Hand off starts fresh on purpose: a handoff note is written, the conversation is cleared, and the fresh conversation starts from the note. Best at a milestone (a PR merged, a phase done), when most of the context is history or failed attempts, or after many hours in one session. Every turn afterwards is cheaper and cleaner. It is only as good as the note, which is what the command automates.

The mods that offer it do so at an absolute token count (150k by default), not a share of the window, because a 1M window is capacity for reading a lot at once, not a target: per-turn cost, cache-miss cost and latency grow with the prefix and quality thins, and Anthropic's own compaction defaults to 150k.

What it does, in order, and why nothing is lost if a step fails

The command answers at once (handoff: confirm in the dialog. …) and runs the rest a moment later, once its own answer is drawn:

  1. Asks you to confirm (Hand off or Cancel). Cancel or closing the dialog does nothing. If a turn started while the dialog was open, it stops there too: nothing is written, and the toast says to run /handoff once the turn has finished.
  2. Makes one model call: it asks this session's own model, over this session's own conversation ($.model.fork, served from the session's prompt cache, every tool denied, so it can only write text), for a note of at most 700 words with fixed sections, no credentials and no quotes from the transcript. Your focus, if you gave one, is added as a last paragraph.
  3. Cleans the note (escape codes, control characters and invisible characters removed; cut at 20,000 characters), blanks anything shaped like a secret (API keys, AWS key ids, GitHub and Slack tokens, JSON web tokens, a URL's password, private key blocks, Bearer …, token: …, password=… become [redacted]), and writes it to ~/.claude/handoffs/<date-time>-<repo>-<session id start>.md under a header naming the repo and branch, the old session and how to resume it (claude --resume <id>). If the model gave no note or the file cannot be written, it says so and nothing is cleared.
  4. Checks the turn once more, then runs /clear. If a turn started while the note was being written, the note stays, nothing is cleared, and the toast names the note and says to run /clear (or /handoff) once the turn has finished. The old conversation is not deleted; it stays resumable by its id. If /clear is refused, the note stays and the toast tells you to run /clear yourself.
  5. Leaves a dim line in the fresh conversation, handoff: note at <path> (not sent to the model), so the path survives the toast.
  6. Puts Continue from the handoff note at <path>: read it first, then carry on with its next step. in the prompt box. It is never sent for you: read the note, edit if you like, then press Enter. A draft you typed meanwhile is kept and the line goes under it. If the box cannot take it, the toast tells you to paste the path.

Refused without doing anything:

  • from any origin but your own (typed at the prompt, the Remote Control bridge, the SDK host behind the desktop app) or a mod's button: a peer session, a task notification, a scheduled trigger or an unknown origin cannot clear this conversation;
  • while a turn is running (a mod's button pressed mid-turn): the note would miss the turn's work;
  • while another hand-off is in progress: one dialog at a time. A hand-off whose flow never began (another plugin refused its timer, so no dialog ever opened) stops blocking after 10 seconds and the next /handoff takes over; one that began (its dialog is open, or it is writing the note) is never taken over, and a late timer from a claim that was taken over does nothing.

A mod's button passes no focus; text after /handoff from a mod is ignored, because the focus is labelled as yours.

Install and load

From the marketplace (terminal session):

/plugin install handoff --marketplace Joseph-Ayoub/claude-mods

or install it at user scope from the local marketplace so every session the desktop app or the terminal starts loads it (see mods/README.md):

claude plugin install handoff@joseph-mods --scope user

While developing, load it from this folder: claude --plugin-dir /absolute/path/to/claude-mods/handoff. After claude plugin update, a running session needs a restart to load the new version. Without this mod, mem-gauge's button and the cockpit chip say so in a toast with the install line.

How it works

HookWhy
session.startregisters /handoff (before the plugins beneath start, so mem-gauge and cockpit see it in the command list)
turn.start, turn.complete (main loop only)knows when a turn is running, so a hand-off waits for it; the flag clears even when a hook beneath fails; the prompt text is never read
command.run /handoffchecks the origin, the turn and a hand-off in flight, answers at once, and starts the flow from a zero-delay timer (the engine runs /clear and the prompt fill only outside a command's own hook); any failure still answers the command (handoff: could not start; nothing was done.)

Privacy

  • One model call, the fork in step 2, only after you confirm. It reads this session's own conversation, nothing else.
  • One file, the note, under ~/.claude/handoffs/: one file per hand-off, never in the repository, never removed by the mod: delete them yourself when you no longer need them.
  • One process, git rev-parse --abbrev-ref HEAD in the session's folder (argv only, 3 s), for the branch in the note's header and nothing else. Any failure leaves the repo name alone.
  • Reads HOME (checked absolute, no . or ..) and nothing else from the environment. No state, no options, no network beyond the model call.

Tests

claude plugin validate mods/handoff and claude plugin test mods/handoff. The tests cover:

  • the note's pure half: the prompt's sections and rules, each secret shape and a false-positive guard, idempotent redaction, cleaning of escape codes, controls, bidi and zero-width characters, the 20,000-character cut, the file name (UTC stamp, repo key, session id prefix, a crafted id cannot add path segments) and the header;
  • /handoff from the composer: the answer before the flow, the dialog text, the order fork, write, /clear, log line, fill, the note's header and path, replace mode, and append under a draft;
  • the focus: cleaned to one line, cut at 300, dropped when nothing printable is left, ignored from a plugin;
  • origins: composer, bridge, SDK and a plugin (mem-gauge, cockpit) accepted; a task notification, a peer, a scheduled trigger, a channel and an unclassified origin refused with nothing done;
  • guards: a main-loop turn in flight (a subagent's turn ending does not count), a failing hook beneath turn.complete (the turn still ends), a second /handoff in flight, two runs before the timer fires, a refused timer (in flight for 10 seconds, then taken over), a taken-over claim's late timer (no second dialog), a started hand-off never taken over, a refused clock read (the command is still answered); a turn started while the dialog is open (nothing written) or between the write and /clear (the note stays, nothing cleared);
  • nothing cleared when a step fails: Cancel, a dismissed dialog, nothing to fork, an API error, an empty reply, a note empty once cleaned, a refused write, an unusable HOME, a session id of the wrong shape; a refused /clear (the note stays); a composer that will not take the fill;
  • the label: git failing or refused (the repo name alone), a repo name that is only dots (the file is named session), a repo name with a space and a bidi override; a hostile note cleaned and redacted on disk.

Limits

  • Not yet seen live on the desktop app: /clear run from the timer after the command answered, and the prompt fill after it. The test kit proves the order; confirm with one real hand-off before relying on it.
  • The guard against a running turn is per load: a hot reload mid-turn forgets the turn until the next one starts.
  • A turn that starts mid-flow is caught at three points: when /handoff is run, after the dialog answers, and right before /clear. One sent in the instant between that last check and /clear running is not caught.
  • Redaction is a fixed list of secret shapes under the prompt's own rule against credentials; a shape not on it relies on that rule.
  • The note is only as good as the model's summary of the conversation; read it before pressing Enter.
Source 2 files
hooks/register.ts 343 lines
1// handoff: the /handoff command. A handoff note is written from this
2// conversation (one tool-less model call over it), cleaned and redacted, saved
3// under ~/.claude/handoffs/; then the conversation is cleared and the next
4// prompt is pre-filled to continue from the note, never sent. mem-gauge's
5// Hand off button and the cockpit band's Hand off chip run this command, so
6// the flow lives in one place.
7//
8// The engine requires `$` to be handed only to functions declared at the top of
9// this file, so every function that touches `$` lives here; the note's prompt,
10// cleaning, redaction and path are pure and live in handoff.ts.
11
12import type { EngineInterface, Register } from 'claude-code'
13
14import { HANDOFF_PROMPT, handoffFile, handoffPath } from './handoff'
15
16type Dollar = EngineInterface
17
18/**
19 * The origins that may start a hand-off: the person's own (their Enter, the
20 * Remote Control bridge, the SDK host behind the desktop app) and a plugin
21 * (mem-gauge's button, the cockpit chip; the person still confirms in the
22 * dialog). Every other kind (a peer session, a task notification, a scheduled
23 * trigger, a channel, an unknown or unstamped origin) is refused: nothing
24 * outside the person's reach may clear this conversation.
25 */
26const ACCEPTED_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk', 'plugin'])
27/** The origins whose `args` are the person's own words, and so may become the note's focus. */
28const PERSON_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
29const HAND_OFF = 'Hand off'
30/** The focus line's cap, in code points: a sentence or two, never a pasted document. */
31const FOCUS_MAX = 300
32const GIT_TIMEOUT_MS = 3000
33/**
34 * How long a claim the flow never picked up blocks another /handoff. The flow
35 * runs from a zero-delay timer; when another plugin refuses that timer the
36 * refusal arrives later and the flow never starts, so a claim older than this
37 * that was never started is taken over instead of blocking until a reload.
38 */
39const CLAIM_STALE_MS = 10_000
40
41const TEXT_REFUSED_ORIGIN = "handoff: runs only from the composer or a mod's button; nothing was done."
42const TEXT_TURN_RUNNING = 'handoff: a turn is running; run /handoff once it has finished, so the note covers it.'
43const TEXT_IN_FLIGHT = 'handoff: a hand-off is already in progress.'
44const TEXT_NOT_STARTED = 'handoff: could not start; nothing was done.'
45const TEXT_TURN_MEANWHILE = 'handoff: a turn started meanwhile; run /handoff once it has finished.'
46const TEXT_SCHEDULED =
47  'handoff: confirm in the dialog. The note is written to ~/.claude/handoffs/ before anything is cleared; if any step fails, nothing is cleared.'
48const QUESTION =
49  'A handoff note is written from this conversation, the conversation is cleared (the old one stays resumable by its id), and the next prompt is pre-filled to continue from the note. Hand off to a fresh session?'
50
51// Per-load plumbing, not state: register() starts these over, so a hot reload
52// never inherits a claim or a turn it did not see begin.
53// True while a main-loop turn runs (turn.start to turn.complete): the note would
54// miss the turn's work and /clear would cut it short.
55let turnInFlight = false
56// The hand-off in flight, under a token so only the run that claimed releases
57// (a reload resets it; a timer from before it can no longer clear a new claim).
58// `at` is when the hook took it; `isStarted` turns true once the flow picks it
59// up, so a claim whose timer never ran goes stale (CLAIM_STALE_MS) and heals.
60let claim: { token: number; at: number; isStarted: boolean } | null = null
61let nextClaim = 0
62
63// Small copies of mem-gauge's helpers (parse.ts, advice.ts, register.tsx). Mods
64// are self-contained plugins with no shared module between them, the same
65// reason rd-ledger and usage-gauge each carry the price table.
66
67/** An error's kind only: a message could carry a path or command output. */
68const kindOf = (err: unknown): string => (err instanceof Error && err.name ? err.name : 'error')
69
70/** An engine session id as a file-name piece: letters, digits, `_` and `-`, at most 128. */
71const isSessionId = (id: string): boolean => /^[A-Za-z0-9_-]{1,128}$/.test(id)
72
73/**
74 * A HOME the note's path may be built under: absolute, no `.` or `..` segment
75 * (so it cannot spell the disk root as `/.` or `/Users/..`), at least one
76 * segment, no control characters. Null otherwise: the hand-off says so and
77 * stops before the model call.
78 */
79function applyHome(home: string | null): string | null {
80  if (!home || !home.startsWith('/') || /[\u0000-\u001f\u007f]/.test(home)) return null
81  const segments = home.split('/').filter(part => part !== '')
82  if (segments.length === 0 || segments.some(part => part === '.' || part === '..')) return null
83  return `/${segments.join('/')}`
84}
85
86/**
87 * Text as one printable line: control characters and line or paragraph
88 * separators become spaces; format characters (bidi overrides, zero-width,
89 * Unicode tags), lone surrogates and private-use code points are dropped; runs
90 * of whitespace collapse; the result is cut to `max` code points. Keeps a repo
91 * name or a typed focus from adding lines to the note's header or the prompt.
92 */
93const oneLine = (text: string, max: number): string =>
94  [
95    ...text
96      .replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ')
97      .replace(/[\p{Cf}\p{Cs}\p{Co}]/gu, '')
98      .replace(/\s+/g, ' ')
99      .trim(),
100  ]
101    .slice(0, max)
102    .join('')
103    .trim()
104
105const basename = (path: string): string => {
106  const trimmed = path.replace(/\/+$/, '')
107  return trimmed.slice(trimmed.lastIndexOf('/') + 1) || trimmed
108}
109
110/** A git branch name safe to put in a header: printable ASCII, one line, at most 100. */
111const cleanBranch = (text: string): string | null => {
112  const line = text.trim().split('\n')[0] ?? ''
113  return /^[\x21-\x7e]{1,100}$/.test(line) ? line : null
114}
115
116/** Why a fork gave no note, for a toast: its reason, an API error's status (or kind when no response came). */
117const forkFailure = (r: { reason: string; status?: number | null; error?: string }): string =>
118  r.reason === 'api-error' ? `api-error ${r.status ?? r.error ?? 'unknown'}` : r.reason
119
120/**
121 * What the person typed after `/handoff`, as the note's focus: one printable
122 * line of at most 300 code points (the input is cut first, so a huge paste
123 * costs nothing); empty when nothing usable was typed.
124 */
125const cleanFocus = (args: string): string => oneLine(args.slice(0, FOCUS_MAX * 4), FOCUS_MAX)
126
127/** The fork's prompt: the fixed note request, plus the person's focus as a closing paragraph when there is one. */
128const forkPrompt = (focus: string): string =>
129  focus === '' ? HANDOFF_PROMPT : `${HANDOFF_PROMPT}\n\nFocus for the next session (from the person): ${focus}`
130
131/**
132 * "<repo basename> · <branch>" for the note's header and file name, from the
133 * session's root and the branch checked out in its working folder (`git
134 * rev-parse --abbrev-ref HEAD`, argv only, 3 s). Any failure (no git, not a
135 * repository, a timeout, an odd branch name) leaves the basename alone.
136 */
137async function sessionLabel($: Dollar): Promise<string> {
138  let name = 'session'
139  try {
140    name = oneLine(basename(await $.session.root()), 100) || 'session'
141    const r = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: await $.session.cwd(), timeoutMs: GIT_TIMEOUT_MS })
142    const branch = r.exitCode === 0 && !r.isStdoutTruncated ? cleanBranch(r.stdout) : null
143    return branch ? `${name} · ${branch}` : name
144  } catch {
145    return name
146  }
147}
148
149/**
150 * Hand this session off to a fresh one, after the person confirms: one
151 * tool-less model call over this session's own transcript ($.model.fork,
152 * served from its prompt cache) writes a note, which is cleaned, redacted and
153 * written under ~/.claude/handoffs/; then /clear runs and the composer is
154 * pre-filled with a line pointing at the note. The person presses Enter: the
155 * fresh session's first prompt is never sent on its own.
156 *
157 * The order is the safety: nothing is cleared until the note is on disk, so a
158 * dismissed dialog, a refused fork, an empty reply or a failed write leaves the
159 * conversation as it was, and a refused /clear leaves the note and says where
160 * it is. The old conversation stays resumable by the id the header names. The
161 * only process is `git rev-parse` for the label; the only file is the note.
162 *
163 * A turn may start while the dialog is open or the note is being written (the
164 * person typed and sent): the turn is checked again after the dialog, so
165 * nothing is written, and right before /clear, so the note stays and nothing
166 * is cleared; either way a toast says to run it once the turn has finished.
167 *
168 * Runs from a zero-delay timer the /handoff hook set (the engine refuses queued
169 * work such as /clear from inside command.run); `token` is the claim the hook
170 * took, marked started first and released here whatever happens. A run whose
171 * claim was taken over (it went stale before this timer ran) does nothing.
172 */
173async function runHandoff($: Dollar, focus: string, token: number): Promise<void> {
174  if (claim?.token !== token) return
175  claim.isStarted = true
176  try {
177    let choice: string
178    try {
179      // AskUserQuestion wants the question to end in a question mark, so what happens comes first.
180      choice = await $.ui.ask(QUESTION, { options: [HAND_OFF, 'Cancel'], header: HAND_OFF })
181    } catch {
182      return // dismissed, or nobody to ask (-p)
183    }
184    if (choice !== HAND_OFF) return
185    if (turnInFlight) {
186      $.ui.toast(TEXT_TURN_MEANWHILE)
187      return
188    }
189
190    const sessionId = await $.session.id()
191    if (!isSessionId(sessionId)) {
192      $.ui.toast('handoff: hand off failed (no session id); nothing was cleared.')
193      return
194    }
195    // Checked before the model call, so a note that could never be written costs nothing.
196    const home = applyHome((await $.env.get('HOME')) ?? null)
197    if (!home) {
198      $.ui.toast('handoff: could not write the handoff note (HOME unknown); nothing was cleared.')
199      return
200    }
201    const label = await sessionLabel($)
202
203    const r = await $.model.fork({ prompt: forkPrompt(focus) })
204    if (!r.isAnswered) {
205      $.ui.toast(r.reason === 'nothing-to-fork' ? 'handoff: nothing to hand off yet.' : `handoff: the handoff note could not be written (${forkFailure(r)}).`)
206      return
207    }
208    const now = await $.clock.now()
209    const text = handoffFile(r.text, label, sessionId, now)
210    if (text === '') {
211      $.ui.toast('handoff: the handoff note could not be written (empty-reply).')
212      return
213    }
214    const path = handoffPath(home, label, sessionId, now)
215    try {
216      await $.fs.write(path, text)
217    } catch (err) {
218      $.ui.toast(`handoff: could not write the handoff note (${kindOf(err)}); nothing was cleared.`)
219      return
220    }
221
222    // The fork and the write take seconds: a turn sent meanwhile would be cut short by /clear.
223    if (turnInFlight) {
224      $.ui.toast(`handoff: note written at ${path}; a turn is running, so nothing was cleared. Run /clear (or /handoff) once it has finished.`)
225      return
226    }
227    try {
228      await $.command.run({ command: 'clear' })
229    } catch {
230      $.ui.toast(`handoff: note written at ${path}; run /clear yourself when ready.`)
231      return
232    }
233    try {
234      // A durable line in the fresh conversation (not sent to the model): a toast vanishes.
235      $.ui.log(`handoff: note at ${path}`)
236    } catch {
237      // The toast below still names the path.
238    }
239
240    let isFilled = false
241    try {
242      // A draft the person typed meanwhile is kept: the continue line goes under it.
243      let hasDraft = false
244      try {
245        hasDraft = (await $.prompt.read()).text.trim() !== ''
246      } catch {
247        hasDraft = false
248      }
249      const line = `Continue from the handoff note at ${path}: read it first, then carry on with its next step.`
250      const filled = await $.prompt.fill(hasDraft ? { text: `\n${line}`, mode: 'append' } : { text: line, mode: 'replace' })
251      isFilled = filled.isFilled
252    } catch {
253      // The note and the clear stand; the toast says where the note is.
254    }
255    $.ui.toast(`handoff: note at ${path}; the conversation was cleared.${isFilled ? '' : ' Paste that path into the next prompt.'}`)
256  } catch (err) {
257    $.ui.toast(`handoff: hand off failed (${kindOf(err)}).`)
258  } finally {
259    if (claim?.token === token) claim = null
260  }
261}
262
263/**
264 * The /handoff hook's body: refuses an origin outside the person's reach, a
265 * running turn or a live claim (one not stale: started, or under
266 * CLAIM_STALE_MS old), then takes the claim and sets the zero-delay timer that
267 * runs the flow. Nothing is awaited between the claim check and taking it, so
268 * two dispatches cannot both pass.
269 */
270async function startHandoff($: Dollar, e: { args?: string; origin?: { kind: string } }): Promise<{ text: string }> {
271  const kind = e.origin?.kind
272  if (kind === undefined || !ACCEPTED_ORIGINS.has(kind)) return { text: TEXT_REFUSED_ORIGIN }
273  // Before the clock read: a refused read must not turn "a turn is running" into "could not start".
274  if (turnInFlight) return { text: TEXT_TURN_RUNNING }
275  const now = await $.clock.now()
276  if (turnInFlight) return { text: TEXT_TURN_RUNNING }
277  if (claim !== null && (claim.isStarted || now - claim.at < CLAIM_STALE_MS)) return { text: TEXT_IN_FLIGHT }
278  // A plugin's args are not the person's words, so only the person's own origins set the focus.
279  const focus = PERSON_ORIGINS.has(kind) ? cleanFocus(e.args ?? '') : ''
280  nextClaim += 1
281  const token = nextClaim
282  claim = { token, at: now, isStarted: false }
283  try {
284    $.clock.after(0, () => {
285      void runHandoff($, focus, token)
286    })
287  } catch (err) {
288    if (claim?.token === token) claim = null
289    return { text: `handoff: could not start (${kindOf(err)}); nothing was done.` }
290  }
291  return { text: TEXT_SCHEDULED }
292}
293
294export const register: Register = on => {
295  turnInFlight = false
296  claim = null
297
298  // Registered before next(e), so plugins beneath (mem-gauge, cockpit) see the
299  // command in $.command.list() during their own session.start.
300  on('session.start', async ($, e, next) => {
301    try {
302      await $.command.register({
303        name: 'handoff',
304        description: 'Hand this session off to a fresh one: a note is written to ~/.claude/handoffs/, the conversation is cleared, the next prompt is pre-filled',
305        argumentHint: '[focus for the next session]',
306      })
307    } catch (err) {
308      $.ui.log(`handoff: /handoff not registered (${kindOf(err)})`, { to: 'debug' })
309    }
310    return next(e)
311  })
312
313  // turn.start fires for the main loop only; its prompt text is never read.
314  // A chain beneath that rejects may never see its turn.complete: the flag is undone then.
315  on('turn.start', async (_$, e, next) => {
316    turnInFlight = true
317    try {
318      return await next(e)
319    } catch (err) {
320      turnInFlight = false
321      throw err
322    }
323  })
324
325  // turn.complete fires for subagent turns too: only the main loop's ends the turn.
326  // Cleared in a finally: a hook beneath that fails rejects next(e), and the
327  // flag must not stay stuck (it would refuse every /handoff until a reload).
328  on('turn.complete', async (_$, e, next) => {
329    try {
330      return await next(e)
331    } finally {
332      if (e.agentId === undefined) turnInFlight = false
333    }
334  })
335
336  // Answers at once and runs the flow from a timer: the dialog, the fork and
337  // the /clear are queued work the engine will not run inside command.run.
338  on('command.run', { command: 'handoff' }, async ($, e) => {
339    // Every path answers the command, a failure included (a refused clock read).
340    return startHandoff($, e).catch(() => ({ text: TEXT_NOT_STARTED }))
341  })
342}
343
hooks/handoff.ts 159 lines
1// /handoff's pure half: the prompt the fork answers, the note's cleaning and
2// redaction, and where the note lands. register.ts does the calls
3// ($.model.fork, $.fs.write, /clear, the prompt fill); everything here is
4// testable without an engine. Moved unchanged from mem-gauge 0.2.1, so the
5// note's format and path are the same as before.
6
7/**
8 * What the one model call is asked: a note a fresh session can start from
9 * with none of this conversation. The fork answers over this session's own
10 * transcript (served from its prompt cache) with every tool denied, so it can
11 * only write text; the rules keep credentials and transcript quotes out of a
12 * file that outlives the session.
13 */
14export const HANDOFF_PROMPT = [
15  'Write a handoff note for a fresh Claude Code session that has none of this conversation.',
16  'It continues this work from the note alone, so make it complete and exact.',
17  '',
18  'Plain language, at most 700 words, Markdown with exactly these sections, in this order:',
19  '## Goal',
20  '## Where things stand (what is done, verified, committed, shipped: files, commits, branches, PRs, versions)',
21  '## Decisions and why',
22  '## Next steps (in order, exact enough to act on: paths, commands, who owns each)',
23  '## Waiting on / open questions',
24  '## Gotchas (what failed and why, what not to retry)',
25  '',
26  'Rules:',
27  '- Never include credentials, tokens, keys, passwords, account numbers or personal data. Name where a secret lives instead (for example "the key in the keychain"), never its value.',
28  '- No quotes from the transcript, only its substance.',
29  '- Output the note alone: no preamble, no closing remarks.',
30].join('\n')
31
32/** The note on disk is cut here (code points), so a runaway reply cannot fill the disk. */
33export const NOTE_MAX = 20_000
34export const CUT_MARKER = '… (cut)'
35
36/**
37 * The model's note as text safe to keep in a file a person opens in a
38 * terminal or an editor: line structure kept (`\n`, `\t`), everything that
39 * could repaint the screen or hide text removed. ANSI escape sequences go
40 * whole (not just their ESC, which would leave `[31m` behind), the remaining
41 * C0/C1 controls go, CRLF and lone CR become line ends, line and paragraph
42 * separators become `\n`, and format characters (bidi overrides, zero-width,
43 * Unicode tags), lone surrogates and private-use code points are dropped, the
44 * same classes register.ts `oneLine` drops from one-line text.
45 */
46export function cleanNote(text: string): string {
47  return text
48    .replace(/\r\n?/g, '\n')
49    .replace(/[\u2028\u2029]/g, '\n')
50    // CSI (ESC [ ... final) and its 8-bit form, then OSC (ESC ] ... BEL or ST), then any other ESC pair.
51    .replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g, '')
52    .replace(/(?:\u001b\]|\u009d)[^\u0007\u001b\u009c]*(?:\u0007|\u001b\\|\u009c)?/g, '')
53    .replace(/\u001b[@-_]?/g, '')
54    .split('\n')
55    .map(line => line.replace(/[\p{Cc}]/gu, ch => (ch === '\t' ? ch : '')).replace(/[\p{Cf}\p{Cs}\p{Co}]/gu, '').replace(/[ \t]+$/, ''))
56    .join('\n')
57    .replace(/\n{3,}/g, '\n\n')
58    .trim()
59}
60
61/** Cuts `text` to `max` code points, marking the cut on its own line. */
62export function capNote(text: string, max = NOTE_MAX): string {
63  const points = [...text]
64  return points.length <= max ? text : `${points.slice(0, max).join('').trimEnd()}\n${CUT_MARKER}`
65}
66
67const REDACTED = '[redacted]'
68
69/**
70 * Secret shapes, each replaced by `[redacted]`: a whole match for a shape that
71 * is the secret itself (an API key, an access key id, a private key block),
72 * the value alone where a label says what it is (`Bearer …`, `token: …`), so
73 * the note still reads. Order matters: the multi-line key block and the
74 * longer `sk-ant-` shape go before their shorter kin.
75 */
76const SECRET_SHAPES: readonly { pattern: RegExp; replace: string }[] = [
77  { pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, replace: REDACTED },
78  // A key block the model cut short still goes, to the end of the text.
79  { pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*$/g, replace: REDACTED },
80  { pattern: /sk-ant-[A-Za-z0-9_-]{8,}/g, replace: REDACTED },
81  { pattern: /sk-proj-[A-Za-z0-9_-]{20,}/g, replace: REDACTED },
82  { pattern: /sk-[A-Za-z0-9]{20,}/g, replace: REDACTED },
83  { pattern: /sk_(?:live|test)_[A-Za-z0-9]{16,}/g, replace: REDACTED },
84  { pattern: /AKIA[0-9A-Z]{16}/g, replace: REDACTED },
85  { pattern: /github_pat_[A-Za-z0-9_]{20,}/g, replace: REDACTED },
86  { pattern: /gh[pousr]_[A-Za-z0-9]{20,}/g, replace: REDACTED },
87  { pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/g, replace: REDACTED },
88  // A JSON web token: three base64url parts, the first a header starting `{"`.
89  { pattern: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, replace: REDACTED },
90  // The password of a URL's user:password@host.
91  { pattern: /(:\/\/[^\s/:@]+:)[^\s@/]+(@)/g, replace: `$1${REDACTED}$2` },
92  { pattern: /(bearer|authorization:)(\s+)[A-Za-z0-9._~+/=-]{16,}/gi, replace: `$1$2${REDACTED}` },
93  // A labelled value, the label quoted or not (`token: …`, `"api_key": "…"`), only when the value
94  // looks like a credential: no spaces, 8+ characters, letters and digits both. A value with no
95  // digit reads as prose ("Secret: lives-in-the-keychain") and stays.
96  {
97    pattern: /(api[_-]?key|secret|token|password)(["']?\s*[:=]\s*["']?)(?=[A-Za-z0-9._~+/=-]*\d)(?=[A-Za-z0-9._~+/=-]*[A-Za-z])[A-Za-z0-9._~+/=-]{8,}/gi,
98    replace: `$1$2${REDACTED}`,
99  },
100]
101
102/**
103 * The note with every secret shape above replaced by `[redacted]`. A last
104 * line of defence under the prompt's own rule against credentials: a model
105 * that quotes an env file or a curl line anyway leaves no usable key on disk.
106 * Pure and idempotent; a short value after a label ("token: abc"), or one with
107 * no digit in it, is left, so ordinary prose about tokens is not mangled. A
108 * fixed list: a shape not on it relies on the prompt's own rule.
109 */
110export function redactSecrets(text: string): string {
111  let out = text
112  for (const { pattern, replace } of SECRET_SHAPES) out = out.replace(pattern, replace)
113  return out
114}
115
116/** The repo part of a "repo · branch" label as a file-name piece: [A-Za-z0-9._-], at most 40, never dot-led; else `session`. */
117export function repoKey(label: string): string {
118  const repo = label.split(' · ')[0] ?? ''
119  const key = repo
120    .replace(/[^A-Za-z0-9._-]+/g, '-')
121    .replace(/^[-.]+/, '')
122    .slice(0, 40)
123    .replace(/[-.]+$/, '')
124  return key || 'session'
125}
126
127const pad = (n: number, width = 2): string => String(n).padStart(width, '0')
128
129/**
130 * `<home>/.claude/handoffs/<yyyy-mm-dd-HHmmss>-<repoKey>-<id8>.md`, the time in
131 * UTC to the second (the same clock as the header's ISO stamp, the same name on
132 * any machine, and a second hand-off a moment later never overwrites the first).
133 * `home` is the checked HOME (register.ts `applyHome`); the id is the engine's
134 * session id, cut to its first 8 safe characters.
135 */
136export function handoffPath(home: string, label: string, sessionId: string, now: number): string {
137  const d = new Date(now)
138  const stamp = `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}-${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}`
139  const id = sessionId.replace(/[^A-Za-z0-9-]/g, '').slice(0, 8) || 'session'
140  return `${home}/.claude/handoffs/${stamp}-${repoKey(label)}-${id}.md`
141}
142
143/**
144 * The file's whole text: the mod's own header (label, time, the old session's
145 * id and how to resume it), a blank line, then the note cleaned, redacted and
146 * capped. Redaction runs before the cap so a key straddling the cut cannot
147 * leave a usable prefix; the cap only removes text, so nothing it leaves can
148 * form a new match. Empty when the note has no text once cleaned.
149 */
150export function handoffFile(note: string, label: string, sessionId: string, now: number): string {
151  const body = capNote(redactSecrets(cleanNote(note)))
152  if (body === '') return ''
153  const header = [
154    `# Handoff — ${label} — ${new Date(now).toISOString()}`,
155    `From session ${sessionId}; the old conversation stays resumable: claude --resume ${sessionId}`,
156  ]
157  return `${header.join('\n')}\n\n${body}\n`
158}
159