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

/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.
/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.
They are not the same thing.
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.
The command answers at once (handoff: confirm in the dialog. …) and runs the rest a moment later, once its own answer is drawn:
/handoff once the turn has finished.$.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.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./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.handoff: note at <path> (not sent to the model), so the path survives the toast.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:
/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.
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.
| Hook | Why |
|---|---|
session.start | registers /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 /handoff | checks 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.) |
~/.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.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.HOME (checked absolute, no . or ..) and nothing else from the environment. No state, no options, no network beyond the model call.claude plugin validate mods/handoff and claude plugin test mods/handoff. The tests cover:
/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;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);/clear (the note stays); a composer that will not take the fill;session), a repo name with a space and a bidi override; a hostile note cleaned and redacted on disk./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./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.hooks/register.ts 343 lines1// 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}
343hooks/handoff.ts 159 lines1// /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