Pass work between Claude Code sessions: save a structured handoff, and on macOS and Linux the next session in the project shows what changed in git since…

Torch passes your work from one Claude Code session to the next. Run /torch:save-handoff before you stop. When you next start Claude Code in that project, or after /clear, a one-line banner shows that the handoff is there, when it was saved and what changed in git since. Your first message brings it to Claude and moves it into an archive, so the session after that starts fresh. This automatic loading runs on macOS and Linux, not on Windows.
Without Torch you would type a load command, confirm it, and choose whether to archive the file, every time. With Torch you just start working.
⏺ torch: Handoff ready: saved 2026-10-06 14:54 GMT+2 (3 h ago) · branch main ✓ · no new commits · clean. Claude gets it with your first message, which archives it.
When the project has moved on since the save, the banner says so, for example ⚠ branch main, handoff was on feature/x, ⚠ HEAD moved since the handoff (91c8eca → 1a2b3c4) or 2 uncommitted changes. Claude mentions that drift before it continues. With your first message:
⏺ torch: Handoff archived to /path/to/project/.claude/handoff-archive/20261006T125432Z.md
/torch:save-handoff. Claude writes handoff-before-clear.md with the goal, what's done, what's next and the git state. Tomorrow, start claude in the project and type "continue". Claude picks up from the handoff, and mentions anything that changed in git overnight./torch:save-handoff, then /clear, then "continue". Your first message in the fresh context brings the handoff with it, and you carry on./torch:load-handoff in a new session. It offers the newest archived handoff, checks git drift and asks before resuming. Alternatively, claude --continue reopens the earlier session with the handoff still in its context./torch:save-handoff. Claude writes handoff-before-clear.md at the project root./exit, then start claude again in the same folder. The banner says the handoff is ready..claude/handoff-archive/.To try it with the sample instead:
handoff-before-clear.md.saved_at to the current time, the output of date -u +%Y-%m-%dT%H:%M:%SZ. A handoff saved more than 14 days ago is announced, not loaded.With your first message, Torch gives Claude the handoff's text and these instructions, as context attached to that message:
- If this message continues the work in the handoff, pick up from it without asking the user to confirm, and first mention any drift above in one line.
- If this message is about something else, leave the handoff aside.
- The user's messages take precedence over the handoff.
They are preceded by:
A handoff too large to include is replaced by its path and an instruction to read it before acting on it. If the file changed between session start and your first message, Claude gets no handoff, and both you and Claude are told what happened and where the file is. If it disappeared, you are told, and Claude gets nothing; the same goes when Torch was reloaded after announcing it (below).
handoff-before-clear.md at the project root, the git top level or else the folder you started in. Each save overwrites it. The archive is never read automatically, and neither is another project's handoff./clear announce it, and only your first message delivers it. Resumed and compacted sessions already have their context.claude -p, the Agent SDK), Cowork, MCP mode, a cloud session, and the GitHub Action, Slack and Teams integrations. Only the session that announced a handoff delivers and archives it, and only a message you send counts: a background task's notification or another session's message leaves the handoff for yours. Only the run of Torch that announced a handoff acts on it: if Torch is reloaded in between, as when an update to it is loaded mid-session, or Claude Code restarts and the session is resumed while its record is still there, your next message neither gives Claude the handoff nor moves it, says so, and leaves the file for /torch:load-handoff. Torch is tested in the terminal; the IDE extensions and the desktop app's Code tab, on your machine or over SSH, run the same Claude Code, but Torch isn't tested there..claude/handoff-archive/<saved-time>.md as the message goes to Claude. It never overwrites an existing archive, and never deletes a handoff that another session saved in the meantime. If it can't finish the move, Torch tells you and Claude where the file is: in rare cases, a hidden .handoff-claim-….md file at the project root. If another hook refuses that first message, Claude doesn't get the handoff and Torch doesn't retry; /torch:load-handoff brings it back from the archive. Commands like /clear, /exit, /model and /effort don't count as messages, so a session you close without typing leaves the handoff for the next one./torch:load-handoff or /load-handoff leaves the file to that skill.saved_at, or the file's modification time without it.# Handoff title, or a symbolic link, is not loaded. The banner says why.Torch is a Claude Code mod: JavaScript in hooks/torch.mjs and hooks/rules.mjs that runs inside Claude Code. It has no server, account or telemetry.
What it sends, and where. Torch makes no network requests of its own. With your first message it adds, as context for Claude:
That context becomes part of your Claude Code conversation, which your Claude service processes like everything else you type.
What it reads from your session.
/torch:load-handoff or /load-handoff.What else it reads. At session start: handoff-before-clear.md; whether a .git folder or file is in the folder you started in or any folder above it, which finds the project root; and two variables Claude Code sets in its own environment, CLAUDE_CODE_ENTRYPOINT and CLAUDE_CODE_SESSION_ATTENDED, only to tell an interactive session from the sessions and hosts rule 3 skips.
Torch stores no message text and logs none. The record it keeps for a session (below) holds no handoff text either.
What it runs, and why. The mods API has no call to move a file, so Torch starts these programs. Each is named with its arguments, written as fixed text in the code except the file paths, and none runs through a shell. They run as you, outside Claude Code's sandbox and its permission rules, like any hook: a deny rule for Claude's tools doesn't apply to them. They get fixed options, file paths and a working folder, never the handoff's text or anything from your conversation, whether as arguments or on standard input. None of them uses the network: git status reads only your local repository.
| When | Command, as run | Why | | - | - | - | | Session start, in a git repository | git status --porcelain=v2 --branch --untracked-files=normal -- . ':(exclude)handoff-before-clear.md' ':(exclude).handoff-claim-*.md' ':(exclude).claude/handoff-archive', in the project folder | Shows what changed in git since the save: branch, commit and uncommitted changes, leaving Torch's own files out | | Session start, in a git repository | git status --porcelain=v2 --untracked-files=normal --ignored=traditional -- handoff-before-clear.md, in the project folder | Tells whether the handoff is committed to the repository, which Torch refuses to load | | First message | mkdir -p -- .claude/handoff-archive, in the project folder | Creates the archive folder the first time | | First message | mv -- <handoff> <claim> | Claims the handoff in one atomic step, by renaming it to a hidden name beside it, so a save from another session can't be lost | | First message | link <claim> <archive or handoff> | Places it in the archive, or gives a newer save back, without ever replacing an existing file | | First message | rm -f -- <claim> | Removes the hidden name once the file is safely in place |
<handoff> is handoff-before-clear.md at the project root; <claim> is .handoff-claim-<random>.md beside it; <archive or handoff> is .claude/handoff-archive/<saved-time>.md, or the handoff's own path. All are full paths.
What it writes.
~/.claude/plugins/store/torch_….json): the handoff's path, its SHA-256 hash, the planned archive path, its save time, the git drift summary, the time of the record and a random id for the run of Torch that wrote it, under the session id. Each session start where Torch runs also tries to delete any record there that is more than 30 days old.<project>/.claude/handoff-archive/ with the commands above. Only the run of Torch that wrote a record acts on it; another run only tries to delete it.The skills run only when you or Claude invoke them:
/torch:save-handoffdate -u, git rev-parse --show-toplevel, git branch --show-current, git rev-parse HEAD, git status --short, git log -1 --oneline, git diff --stat and git check-ignorehandoff-before-clear.md at the project root/torch:load-handoffgit rev-parse --show-toplevel, git branch --show-current, git rev-parse HEAD and git status --shortCLAUDE.md and the files the handoff listsSee PRIVACY.md for retention and contact details.
mkdir, mv, link and rm, which both have. Windows is not supported. Nothing else to install: no Python, no Node.js.Keep handoffs out of commits. Add these three lines to each project's .gitignore; /torch:save-handoff warns when they're missing:
handoff-before-clear.md
.claude/handoff-archive/
.handoff-claim-*.md
Install Torch from Anthropic's plugin directory: with /plugin in Claude Code, or in claude.ai under Customize → Plugins, which reaches Claude Code in the terminal when you sign in there with that claude.ai account. To try a copy of this repository without installing it, start Claude Code with claude --plugin-dir /path/to/torch.
/torch:save-handoff starts every handoff with a front-matter block, which Torch reads for the save time and git state:
---
handoff: 1
saved_at: 2026-10-06T14:54:32Z
branch: main
head: 91c8ecac1f0e3b2a9c7d4e5f60718293a4b5c6d7
---
# Handoff — <short title>
The values are raw command output:
saved_at comes from date -u +%Y-%m-%dT%H:%M:%SZ.branch comes from git branch --show-current, or is (detached).head comes from git rev-parse HEAD, or is none.A handoff without a valid block still loads; the banner then says no saved git state to compare. examples/handoff-before-clear.example.md shows a complete handoff.
handoff-before-clear.md is at the project root and starts with a # Handoff titleclaude --version (2.1.287 or later), then /plugin: its tabs show 1 mod active · torch when Torch's mod has loadedclaude --debug, try again, and open an issue with the line and the debug log.Uninstall Torch with /plugin in Claude Code, or remove it on claude.ai. Torch's session records are in ~/.claude/plugins/store/torch_….json; Claude Code deletes that file once no session has used it for cleanupPeriodDays (30 days by default), and you can delete it yourself at any time. Your handoff files and archives stay in your projects until you delete them.
Report problems and ask questions in GitHub issues. Report security vulnerabilities privately, as SECURITY.md describes.
hooks/torch.mjs 353 lines1// Torch, a Claude Code mod: finds the project's handoff-before-clear.md when a new interactive
2// session starts, gives it to Claude with the user's first message, and moves it into
3// .claude/handoff-archive/ at that moment, so the next session starts fresh.
4//
5// classic.SessionStart (startup, clear) checks the handoff, records it for this session and
6// logs a one-line banner with the git drift since the
7// save; it passes the event on unchanged
8// prompt.submit on the session's first message from the user,
9// archives the handoff and attaches it to that message
10// as context, unless the message is /torch:load-handoff
11// or /load-handoff
12//
13// Every function that is handed `$` is declared in this file, as `claude plugin validate`
14// requires; rules.mjs holds the rules that need no file or process access. Torch never answers
15// or blocks an event: each hook ends in `next(e)` or `next({ ...e, context })`, and a failure is
16// logged while the event goes on.
17import {
18 ARCHIVE_DIR, HANDOFF_NAME, MAX_AGE_DAYS, READ_LIMIT, RECORD_MAX_AGE_DAYS, REFUSALS,
19 ancestors, announcedBanner, archiveStamp, base64ToBytes, contractFields, deliveredTexts,
20 driftParts, hasTitle, humanAge, isOtherHost, isRecord, joinPath, localTime, parentPath,
21 parseStatus, randomHex, sha256Hex, usableSessionId,
22} from './rules.mjs'
23
24const GIT_TIMEOUT_MS = 5_000
25const CLAIM_PREFIX = '.handoff-claim-'
26const ARCHIVE_NAMES = 100 // dest, dest-2, ..., dest-100
27const RECORD_PREFIX = 'session:'
28const LOAD_SKILL = /^\s*\/(?:torch:)?load-handoff(\s|$)/
29const USER_ORIGINS = new Set(['composer', 'bridge'])
30
31// Sessions that have had their first message from the user, kept for the life of the module. A
32// message of a session already here passes on unchanged, whether it overlaps the first or comes
33// after a first message whose record could not be deleted, so a session acts at most once in
34// this run of Torch.
35const answered = new Set()
36
37// This run of Torch, from the module's load to its next reload or the end of the process. Records
38// carry it, and only the run that announced a handoff acts on it, so a record that outlived the
39// set above, after a reload or in a session resumed in a new process, is never acted on.
40const RUN = randomHex(16)
41
42// A failed hook is logged, and its .catch handler passes the event on. In a handler `next` is
43// replay-safe: after the hook's own call it gives that call's result and nothing runs again;
44// before, it runs the hooks beneath once.
45export function register(on) {
46 on('classic.SessionStart', { source: ['startup', 'clear'] }, announceHandoff)
47 .catch(async ($, e, next) => {
48 $.ui.log(`could not announce the handoff: ${next.error?.message ?? 'unknown error'}`)
49 return next(e)
50 })
51 on('prompt.submit', deliverHandoff)
52 .catch(async ($, e, next) => {
53 $.ui.log(`could not deliver the handoff: ${next.error?.message ?? 'unknown error'}`)
54 return next(e)
55 })
56}
57
58export async function announceHandoff($, e, next) {
59 await announce($, e)
60 return next(e)
61}
62
63export async function deliverHandoff($, e, next) {
64 const context = await firstMessageContext($, e)
65 if (context === null) {
66 return next(e)
67 }
68 return next({ ...e, context: [...(e.context ?? []), context] })
69}
70
71// Check this session's handoff, record it for the first message and log the banner; refusals are
72// logged as banners too.
73async function announce($, e) {
74 if (await isSkippedSession($)) return
75 const cwd = typeof e.cwd === 'string' && e.cwd ? e.cwd : await $.session.cwd()
76 if (!cwd.startsWith('/')) {
77 $.ui.log(REFUSALS.platform)
78 return
79 }
80 await removeStaleRecords($)
81 const { root, isRepo } = await projectRoot($, cwd)
82 const handoff = joinPath(root, HANDOFF_NAME)
83 const stat = (await $.fs.exists(handoff)) ? await $.fs.stat(handoff) : await brokenLink($, handoff)
84 if (stat === null) return
85 if (stat.isLink) {
86 $.ui.log(REFUSALS.symlink)
87 return
88 }
89 if (stat.kind !== 'file') return
90 if (stat.size > READ_LIMIT) {
91 $.ui.log(REFUSALS.tooLarge)
92 return
93 }
94
95 const bytes = await readBytes($, handoff)
96 const text = new TextDecoder().decode(bytes)
97 if (!hasTitle(text)) {
98 $.ui.log(REFUSALS.untitled)
99 return
100 }
101 const fields = contractFields(text)
102 const savedTs = fields ? fields.savedTs : stat.mtimeMs / 1000
103 const ageSeconds = Date.now() / 1000 - savedTs
104 const saved = localTime(savedTs)
105 const age = humanAge(ageSeconds)
106 if (ageSeconds > MAX_AGE_DAYS * 86_400) {
107 $.ui.log(REFUSALS.old(saved, age))
108 return
109 }
110
111 const status = isRepo ? await repoStatus($, root) : null
112 if (status !== null && status.handoffTracked === null) {
113 $.ui.log(REFUSALS.unknownTracked)
114 return
115 }
116 if (status !== null && status.handoffTracked) {
117 $.ui.log(REFUSALS.tracked)
118 return
119 }
120
121 const drift = driftParts(status, isRepo, fields)
122 // The archive name by the save time; if it is taken when the first message comes, archive() moves
123 // on to -2, -3 and so on, up to -100.
124 const archiveTo = joinPath(joinPath(root, ARCHIVE_DIR), `${archiveStamp(savedTs)}.md`)
125 const recorded = await recordSession($, usableSessionId(e.session_id), {
126 handoff, sha256: await sha256Hex(bytes), archiveTo, savedTs, drift, at: Date.now(), run: RUN,
127 })
128 $.ui.log(announcedBanner({ handoff, text, saved, age, drift, archiveTo, recorded }))
129}
130
131// The context for this message, or null when it passes on unchanged: it is not from the user, its
132// session has no record, it is not the session's first, it hands the file to /torch:load-handoff
133// or /load-handoff, or another run of Torch wrote the record.
134// On the first message the handoff is archived first, so the context says where it now is.
135async function firstMessageContext($, e) {
136 if (!USER_ORIGINS.has(e.origin?.kind)) return null
137 const sessionId = usableSessionId(await $.session.id())
138 if (sessionId === null || answered.has(sessionId)) return null
139 answered.add(sessionId)
140 // A session on an unsupported platform never announced a handoff, so it has nothing to deliver.
141 if (!(await $.session.cwd()).startsWith('/')) return null
142 const key = RECORD_PREFIX + sessionId
143 const record = await $.store.get(key)
144 if (record === undefined) return null
145 // Delete the record before acting, so a later session never acts on it again.
146 await $.store.delete(key)
147 if (LOAD_SKILL.test(typeof e.text === 'string' ? e.text : '')) return null
148 if (record?.run !== RUN) {
149 $.ui.log(deliveredTexts({ outcome: 'earlier-run' }).banner)
150 return null
151 }
152 if (!isRecord(record)) throw new Error('the session record is not valid')
153 // Everything the report needs is worked out before a file moves.
154 const facts = {
155 handoff: record.handoff, saved: localTime(record.savedTs),
156 age: humanAge(Date.now() / 1000 - record.savedTs), drift: record.drift,
157 }
158 const outcome = await archive($, record.handoff, record.sha256, record.archiveTo)
159 const texts = deliveredTexts({ ...outcome, ...facts })
160 $.ui.log(texts.banner)
161 return texts.context
162}
163
164// The stat of a symbolic link that leads nowhere, which $.fs.exists reports as absent, or null
165// when nothing is at the path.
166async function brokenLink($, file) {
167 let stat
168 try {
169 stat = await $.fs.stat(file)
170 } catch (error) {
171 return null
172 }
173 return stat.isLink ? stat : null
174}
175
176async function isSkippedSession($) {
177 return isOtherHost(await $.env.get('CLAUDE_CODE_ENTRYPOINT'), await $.env.get('CLAUDE_CODE_SESSION_ATTENDED'))
178}
179
180// Whether this session now has a record to deliver by. Without a usable id, or when the store
181// refuses the write, nothing is delivered and the banner says to load the handoff by hand.
182async function recordSession($, sessionId, record) {
183 if (sessionId === null) return false
184 try {
185 await $.store.set(RECORD_PREFIX + sessionId, record)
186 return true
187 } catch (error) {
188 return false
189 }
190}
191
192async function removeStaleRecords($) {
193 const cutoff = Date.now() - RECORD_MAX_AGE_DAYS * 86_400_000
194 for (const key of await $.store.keys()) {
195 if (!key.startsWith(RECORD_PREFIX)) continue
196 const record = await $.store.get(key)
197 if (!(record && typeof record.at === 'number' && record.at >= cutoff)) await $.store.delete(key)
198 }
199}
200
201// The enclosing work tree's top level (a folder holding a .git directory or file), else cwd.
202async function projectRoot($, cwd) {
203 const chain = ancestors(cwd)
204 for (const folder of chain) {
205 if (await $.fs.exists(joinPath(folder, '.git'))) return { root: folder, isRepo: true }
206 }
207 return { root: chain[0], isRepo: false }
208}
209
210// Two git status calls side by side, run in the project root and written out in full: the work
211// tree without the handoff, a claim archive() left and the archive folder, so archiving never
212// counts as a change; and the handoff alone with ignored files shown, which says whether it is
213// tracked. Asking about ignored files for the whole tree would walk every ignored folder.
214async function repoStatus($, root) {
215 const [tree, own] = await Promise.all([
216 gitOutput($.process.run(['git', 'status', '--porcelain=v2', '--branch', '--untracked-files=normal',
217 '--', '.', ':(exclude)handoff-before-clear.md', ':(exclude).handoff-claim-*.md',
218 ':(exclude).claude/handoff-archive'], { cwd: root, timeoutMs: GIT_TIMEOUT_MS })),
219 gitOutput($.process.run(['git', 'status', '--porcelain=v2', '--untracked-files=normal',
220 '--ignored=traditional', '--', 'handoff-before-clear.md'], { cwd: root, timeoutMs: GIT_TIMEOUT_MS })),
221 ])
222 return parseStatus(tree, own)
223}
224
225// Git's complete standard output, or null when git failed, could not run or was cut short.
226async function gitOutput(running) {
227 let run
228 try {
229 run = await running
230 } catch (error) {
231 return null
232 }
233 return run.exitCode === 0 && !run.isStdoutTruncated ? run.stdout : null
234}
235
236async function readBytes($, file) {
237 const { base64 } = await $.fs.read(file, { as: 'bytes' })
238 return base64ToBytes(base64)
239}
240
241// Move the announced handoff into the archive without losing or replacing any file.
242//
243// Renaming the live file to a private name beside it claims it: one rename in one folder,
244// atomic, and the file keeps its identity, so a save another session is still writing lands in
245// it. The claim is then hard-linked into place, back to the live path when it is not the
246// handoff this session announced, otherwise into the archive, and only then is its private name
247// removed. `link` never replaces a file. When no link can be made, the claim stays where it is:
248// "kept" when it is the announced handoff, "kept-other" when it is a later save, "kept-unread"
249// when it could not be read to tell. Every outcome names where the file is; "archived" and
250// "kept" also give the text that was read, which is the announced handoff's.
251export async function archive($, handoff, loadedSha, dest) {
252 const made = await $.process.run(['mkdir', '-p', '--', '.claude/handoff-archive'], { cwd: parentPath(handoff) })
253 if (made.exitCode !== 0) throw new Error(`mkdir failed: ${made.stderr.trim()}`)
254 const claim = joinPath(parentPath(handoff), `${CLAIM_PREFIX}${randomHex(16)}.md`)
255 const failure = await claimFailure($, handoff, claim)
256 if (failure === 'gone') return { outcome: 'gone', place: null }
257
258 let bytes
259 try {
260 bytes = await readBytes($, claim)
261 } catch (error) {
262 return { outcome: 'kept-unread', place: claim }
263 }
264 const matched = (await sha256Hex(bytes)) === loadedSha
265 const text = matched ? new TextDecoder().decode(bytes) : undefined
266 let outcome
267 let place = null
268 if (matched) {
269 place = await linkFree($, claim, dest)
270 outcome = place === null ? 'kept' : 'archived'
271 } else {
272 const back = await tryLink($, claim, handoff)
273 if (back === 'taken') place = await linkFree($, claim, dest)
274 outcome = back === 'linked' || place !== null ? 'changed' : 'kept-other'
275 }
276 if (outcome === 'kept') return { outcome, place: claim, text }
277 if (outcome === 'kept-other') return { outcome, place: claim }
278 const leftover = await removeClaim($, claim)
279 const result = matched ? { outcome, place, text } : { outcome, place }
280 return leftover === null ? result : { ...result, leftover }
281}
282
283// Null once the handoff is claimed, 'gone' when it and the claim are both absent; throws when the
284// claim failed and the handoff is untouched, or when it cannot be told which happened. A failed
285// mv does not prove the rename didn't happen, so what is on disk decides.
286async function claimFailure($, handoff, claim) {
287 let reason
288 try {
289 const moved = await $.process.run(['mv', '--', handoff, claim])
290 if (moved.exitCode === 0) return null
291 reason = moved.stderr.trim()
292 } catch (error) {
293 reason = error instanceof Error ? error.message : String(error)
294 }
295 // A claim that exists decides by itself; the live path matters only when it doesn't.
296 let live
297 try {
298 if (await $.fs.exists(claim)) return null
299 live = await $.fs.exists(handoff)
300 } catch (error) {
301 throw new Error(`could not tell whether ${handoff} was moved to ${claim}: ${reason}`)
302 }
303 if (!live) return 'gone'
304 throw new Error(`mv could not claim ${handoff}: ${reason}`)
305}
306
307// Hard-link source at dest, or at dest-2, dest-3, ... up to dest-100 while a name is taken; null
308// when no link can be made, or every one of those names is taken. The bound matters because a
309// hook's time limit doesn't count the time its mods API calls take.
310async function linkFree($, source, dest) {
311 const stem = dest.slice(0, -'.md'.length)
312 for (let n = 1; n <= ARCHIVE_NAMES; n += 1) {
313 const target = n === 1 ? dest : `${stem}-${n}.md`
314 const linked = await tryLink($, source, target)
315 if (linked === 'linked') return target
316 if (linked === 'failed') return null
317 }
318 return null
319}
320
321// 'linked', 'taken' when something is already at target, or 'failed', which includes not being
322// able to tell: the caller then keeps the source where it is.
323async function tryLink($, source, target) {
324 let linked
325 try {
326 linked = (await $.process.run(['link', source, target])).exitCode === 0
327 } catch (error) {
328 linked = false // a call that failed may still have linked: what is at target decides
329 }
330 if (linked) return 'linked'
331 try {
332 return (await $.fs.exists(target)) ? 'taken' : 'failed'
333 } catch (error) {
334 return 'failed'
335 }
336}
337
338// Null once the claim's name is gone, else the claim, which may remain and is reported.
339async function removeClaim($, claim) {
340 let removed
341 try {
342 removed = (await $.process.run(['rm', '-f', '--', claim])).exitCode === 0
343 } catch (error) {
344 removed = false // a call that failed may still have removed it: whether it is there decides
345 }
346 if (removed) return null
347 try {
348 return (await $.fs.exists(claim)) ? claim : null
349 } catch (error) {
350 return claim
351 }
352}
353hooks/rules.mjs 279 lines1// Torch's rules that need no access to files or processes: reading the handoff contract,
2// describing git drift, and the texts Torch shows the user and gives Claude. torch.mjs does
3// the reading, running and writing.
4
5export const HANDOFF_NAME = 'handoff-before-clear.md'
6export const ARCHIVE_DIR = '.claude/handoff-archive'
7export const MAX_AGE_DAYS = 14 // older handoffs are announced, not loaded
8export const CONTEXT_LIMIT = 9_800 // Torch inlines a handoff up to this many UTF-16 units
9export const RECORD_MAX_AGE_DAYS = 30 // records of sessions that never sent a prompt
10export const READ_LIMIT = 4 * 1024 * 1024 // $.fs.read refuses larger files
11
12// Entry points of hosts other than interactive Claude Code on the user's machine, as Claude Code
13// names them: Cowork, cloud sessions, the SDK, MCP mode, the GitHub Action, Slack and Teams.
14const OTHER_HOSTS = new Set(['local-agent', 'local_agent', 'mcp', 'claude-code-github-action',
15 'claude-in-teams', 'claude_in_slack', 'claude-in-slack'])
16
17export function isOtherHost(entrypoint, attended) {
18 if (attended === '0') return true
19 const name = entrypoint ?? ''
20 return OTHER_HOSTS.has(name) || name.includes('cowork') || name.startsWith('sdk-') || name.startsWith('remote')
21}
22
23export function usableSessionId(sessionId) {
24 return typeof sessionId === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(sessionId) ? sessionId : null
25}
26
27export function hasTitle(text) {
28 return /^# Handoff(\s.*)?$/m.test(text)
29}
30
31// Python's str.splitlines boundaries, which v1 split the front matter on.
32const LINE_BREAK = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/
33const CONTRACT_KEYS = ['branch', 'handoff', 'head', 'saved_at']
34
35// The contract's front matter, or null when it is missing or not exactly valid. Values are
36// single-line command output taken literally; a quoted value, a missing or repeated key, or a
37// value of the wrong shape makes the whole block count as absent.
38export function contractFields(text) {
39 const lines = text.split(LINE_BREAK)
40 const end = lines.indexOf('---', 1)
41 if (lines[0] !== '---' || end === -1) return null
42 const fields = Object.create(null)
43 for (const line of lines.slice(1, end)) {
44 const at = line.indexOf(':')
45 if (at === -1) return null
46 const key = line.slice(0, at)
47 if (Object.hasOwn(fields, key)) return null
48 fields[key] = line.slice(at + 1).trim()
49 }
50 if (Object.keys(fields).sort().join() !== CONTRACT_KEYS.join() || fields.handoff !== '1') return null
51 const savedTs = utcSeconds(fields.saved_at)
52 if (savedTs === null) return null
53 if (!(fields.head === 'none' || /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(fields.head))) return null
54 if (!/^[^\s"'`]+$/.test(fields.branch)) return null
55 return { ...fields, savedTs }
56}
57
58// Seconds since the epoch for a real UTC time written as `date -u +%Y-%m-%dT%H:%M:%SZ` prints it.
59function utcSeconds(value) {
60 const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/.exec(value)
61 if (!m) return null
62 const [year, month, day, hour, minute, second] = m.slice(1).map(Number)
63 if (year === 0) return null
64 // setUTCFullYear, unlike Date.UTC, keeps years 1 to 99 as written.
65 const back = new Date(0)
66 back.setUTCFullYear(year, month - 1, day)
67 back.setUTCHours(hour, minute, second, 0)
68 const ms = back.getTime()
69 const exact = back.getUTCFullYear() === year && back.getUTCMonth() === month - 1 && back.getUTCDate() === day
70 && back.getUTCHours() === hour && back.getUTCMinutes() === minute && back.getUTCSeconds() === second
71 return exact ? ms / 1000 : null
72}
73
74// Branch, HEAD, uncommitted changes and whether the handoff is tracked, from the two git
75// status calls (porcelain v2). `tree` is the work tree without the handoff and the archive;
76// `own` is the handoff alone with ignored files shown. Either is null when its call failed.
77export function parseStatus(tree, own) {
78 const facts = { tree: tree !== null, branch: null, head: null, changes: 0, handoffTracked: null }
79 if (own !== null) facts.handoffTracked = !own.split('\n').some((line) => ['? ', '! '].includes(line.slice(0, 2)))
80 for (const line of (tree ?? '').split('\n')) {
81 if (line.startsWith('# branch.head ')) facts.branch = line.slice('# branch.head '.length)
82 else if (line.startsWith('# branch.oid ')) {
83 const oid = line.slice('# branch.oid '.length)
84 facts.head = oid === '(initial)' ? null : oid
85 } else if (['1 ', '2 ', 'u ', '? '].includes(line.slice(0, 2))) facts.changes += 1
86 }
87 return facts
88}
89
90export function driftParts(status, isRepo, fields) {
91 if (!isRepo) return ['not a git repo']
92 if (status === null || !status.tree) return ['git state unavailable']
93 const { branch, head, changes } = status
94 const savedBranch = fields && fields.branch !== 'none' ? fields.branch : null
95 const savedHead = fields && fields.head !== 'none' ? fields.head : null
96 const parts = [savedBranch === null ? `branch ${branch}`
97 : savedBranch === branch ? `branch ${branch} ✓`
98 : `⚠ branch ${branch}, handoff was on ${savedBranch}`]
99 if (savedHead && head) {
100 parts.push(head === savedHead ? 'no new commits'
101 : `⚠ HEAD moved since the handoff (${savedHead.slice(0, 7)} → ${head.slice(0, 7)})`)
102 } else if (fields === null) {
103 parts.push('no saved git state to compare')
104 }
105 parts.push(changes === 0 ? 'clean' : `${changes} uncommitted change${changes === 1 ? '' : 's'}`)
106 return parts
107}
108
109export function humanAge(seconds) {
110 const minutes = Math.max(0, Math.floor(seconds / 60))
111 if (minutes < 90) return `${minutes} min`
112 if (minutes < 36 * 60) return `${Math.floor(minutes / 60)} h`
113 return `${Math.floor(minutes / (24 * 60))} d`
114}
115
116// The local date and time, as `2026-10-06 14:54 GMT+2`.
117export function localTime(seconds) {
118 const when = new Date(seconds * 1000)
119 const pad = (n) => String(n).padStart(2, '0')
120 const zone = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).formatToParts(when)
121 .find((part) => part.type === 'timeZoneName')?.value
122 return `${when.getFullYear()}-${pad(when.getMonth() + 1)}-${pad(when.getDate())} `
123 + `${pad(when.getHours())}:${pad(when.getMinutes())}${zone ? ` ${zone}` : ''}`
124}
125
126// The archive name's stamp, the save time in UTC: 20261006T145432Z.
127export function archiveStamp(seconds) {
128 return new Date(seconds * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z').replaceAll('-', '').replaceAll(':', '')
129}
130
131export function joinPath(folder, name) {
132 return folder.endsWith('/') ? `${folder}${name}` : `${folder}/${name}`
133}
134
135export function parentPath(file) {
136 const at = file.lastIndexOf('/')
137 return at <= 0 ? '/' : file.slice(0, at)
138}
139
140// The folder and each of its parents up to the file system root, nearest first.
141export function ancestors(folder) {
142 const parts = folder.split('/').filter(Boolean)
143 const chain = []
144 for (let n = parts.length; n > 0; n -= 1) chain.push(`/${parts.slice(0, n).join('/')}`)
145 chain.push('/')
146 return chain
147}
148
149export const GUIDANCE = [
150 '- If this message continues the work in the handoff, pick up from it without asking the user to '
151 + 'confirm, and first mention any drift above in one line.',
152 '- If this message is about something else, leave the handoff aside.',
153 "- The user's messages take precedence over the handoff.",
154]
155
156// What Claude reads with the first message when the handoff is delivered: the header, then the
157// handoff itself, or where to read it when the whole would pass CONTEXT_LIMIT UTF-16 units.
158function handoffContext({ handoff, place, location, saved, age, drift, text }) {
159 const header = "This project's handoff file was loaded automatically with this message (torch plugin). "
160 + 'Torch does not check who wrote it.\n\n'
161 + `File: ${handoff}, saved ${saved}, ${age} ago. ${location}\n`
162 + `Git at session start, compared with the handoff: ${drift.join('; ')}.\n\n`
163 + `How to use it:\n${GUIDANCE.join('\n')}\n`
164 const inline = `${header}\n<handoff>\n${text.trimEnd()}\n</handoff>`
165 if (inline.length <= CONTEXT_LIMIT) return { context: inline, inline: true }
166 const size = [...text].length.toLocaleString('en-US')
167 return {
168 context: `${header}\nThe handoff is ${size} characters, too long to include here. Read it at ${place} `
169 + 'before acting on it.',
170 inline: false,
171 }
172}
173
174// The banner at session start. Whether the handoff will be pointed to rather than included is
175// judged with the archive name planned now.
176export function announcedBanner({ handoff, text, saved, age, drift, archiveTo, recorded }) {
177 const facts = `saved ${saved} (${age} ago) · ${drift.join(' · ')}`
178 if (!recorded) {
179 return `Handoff found: ${facts}. Torch could not record this session, so run /torch:load-handoff to use it.`
180 }
181 const { inline } = handoffContext({
182 handoff, place: archiveTo, location: `It is now archived at ${archiveTo}.`, saved, age, drift, text,
183 })
184 const size = inline ? '' : ` · ${[...text].length.toLocaleString('en-US')} chars, Claude reads it from the file`
185 return `Handoff ready: ${facts}${size}. Claude gets it with your first message, which archives it.`
186}
187
188// What the user and Claude are told with the first message, by the archive's outcome, or
189// "earlier-run" when another run of Torch wrote the record; what that run did is unknown, so its
190// notice says only what this message did. The handoff is delivered only when it was
191// read and is the one announced: "archived" and "kept".
192export function deliveredTexts({ outcome, place, handoff, leftover, text, saved, age, drift }) {
193 const extra = leftover ? ` Torch could not remove its temporary name for it, ${leftover}, which may remain.` : ''
194 const none = 'No handoff was delivered with this message.'
195 switch (outcome) {
196 case 'archived':
197 return {
198 banner: `Handoff archived to ${place}${leftover ? `.${extra}` : ''}`,
199 context: handoffContext({ handoff, place, location: `It is now archived at ${place}.${extra}`, saved, age, drift, text }).context,
200 }
201 case 'kept':
202 return {
203 banner: `Could not archive ${HANDOFF_NAME}: it could not be linked into the archive. It is kept at ${place}.`,
204 context: handoffContext({ handoff, place, location: `It could not be archived and is now at ${place}.`, saved, age, drift, text }).context,
205 }
206 case 'changed':
207 return {
208 banner: `${HANDOFF_NAME} changed after it was announced, so Claude didn't get it; the file was left for the `
209 + `next session${place ? ` (an earlier save is kept in ${place})` : ''}.${extra}`,
210 context: `${none} Another session saved a newer handoff after this session announced its own. The file at `
211 + `${handoff} is that newer handoff, not the one announced at session start`
212 + (place ? `; an intermediate save is kept at ${place}.` : '.') + extra,
213 }
214 case 'kept-other':
215 return {
216 banner: `${HANDOFF_NAME} changed after it was announced and could not be put back, so Claude didn't get it. `
217 + `The newer file is at ${place}.`,
218 context: `${none} A handoff saved after this session announced its own is now at ${place}. It is not the `
219 + 'handoff announced at session start.',
220 }
221 case 'kept-unread':
222 return {
223 banner: `Could not archive ${HANDOFF_NAME}: the file could not be read, so Claude didn't get it. It is kept at ${place}.`,
224 context: `${none} The handoff file is now at ${place}. It could not be read, so it is unknown whether `
225 + 'it is the one announced at session start.',
226 }
227 case 'gone':
228 return { banner: `${HANDOFF_NAME} was gone before your first message, so Claude didn't get it.`, context: null }
229 case 'earlier-run':
230 return {
231 banner: "This session's handoff record is from an earlier run of Torch, so Torch didn't give Claude a "
232 + 'handoff or move one with this message. Run /torch:load-handoff if you need it.',
233 context: null,
234 }
235 default:
236 throw new Error(`unknown archive outcome: ${outcome}`)
237 }
238}
239
240export const REFUSALS = {
241 symlink: `${HANDOFF_NAME} is a symbolic link, so it was not loaded or moved. Run /torch:load-handoff to use it.`,
242 untitled: `${HANDOFF_NAME} has no '# Handoff' title, so it was not loaded. Run /torch:load-handoff to look at it.`,
243 tooLarge: `${HANDOFF_NAME} is over 4 MiB, so it was not loaded. Run /torch:load-handoff to look at it.`,
244 unknownTracked: `Could not check with git whether ${HANDOFF_NAME} is committed to this repo, so it was not `
245 + 'loaded. Run /torch:load-handoff if it is yours.',
246 tracked: `${HANDOFF_NAME} is committed to this repo, so it was not loaded. Run /torch:load-handoff if it is yours.`,
247 platform: 'Torch supports macOS and Linux; the handoff was not loaded.',
248 old: (saved, age) => `A handoff from ${saved} (${age} old) is here but was not loaded: it is older than `
249 + `${MAX_AGE_DAYS} days. Run /torch:load-handoff to use it.`,
250}
251
252export function base64ToBytes(base64) {
253 const binary = atob(base64)
254 const bytes = new Uint8Array(binary.length)
255 for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
256 return bytes
257}
258
259export async function sha256Hex(bytes) {
260 const digest = await crypto.subtle.digest('SHA-256', bytes)
261 return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
262}
263
264export function randomHex(byteCount) {
265 return [...crypto.getRandomValues(new Uint8Array(byteCount))]
266 .map((byte) => byte.toString(16).padStart(2, '0')).join('')
267}
268
269// JavaScript dates reach 8.64e15 ms either side of 1970.
270const MAX_DATE_SECONDS = 8.64e12
271
272// A session record as announce() writes it.
273export function isRecord(value) {
274 return value !== null && typeof value === 'object'
275 && ['handoff', 'sha256', 'archiveTo'].every((key) => typeof value[key] === 'string')
276 && Number.isFinite(value.savedTs) && Math.abs(value.savedTs) <= MAX_DATE_SECONDS
277 && Array.isArray(value.drift) && value.drift.every((part) => typeof part === 'string')
278}
279