SLOPSHOPPER

Torch

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…

newpromptprocess
v2.1.1MITupdated 2026-10-07PetroczyP/torch
A shopper browsing a rack in a slop shop
README

Torch

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.

What you see

⏺ 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

Examples

  1. End of the day. Type /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.
  2. Context is running low mid-task. Type /torch:save-handoff, then /clear, then "continue". Your first message in the fresh context brings the handoff with it, and you carry on.
  3. You closed a session right after your first message. That first message archived the handoff, but the work never happened. Type /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.

Try it

  1. Start Claude Code with Torch installed, in any git repository, and ask for something small.
  2. Type /torch:save-handoff. Claude writes handoff-before-clear.md at the project root.
  3. Type /exit, then start claude again in the same folder. The banner says the handoff is ready.
  4. Type "continue". The handoff reaches Claude with that message, Claude picks up from it, and the file moves to .claude/handoff-archive/.

To try it with the sample instead:

  1. Copy examples/handoff-before-clear.example.md to a project root as handoff-before-clear.md.
  2. Set its 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.

What Torch tells Claude

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 line saying the handoff was loaded automatically with this message and that Torch does not check who wrote it
  • the handoff's path, its save time, and where it is now: in the archive, or where it was kept when the move couldn't finish
  • the git drift summary from session start

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).

How it decides

  1. Only the newest handoff is loaded. That's 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.
  2. Only new sessions and /clear announce it, and only your first message delivers it. Resumed and compacted sessions already have their context.
  3. Headless runs and other hosts skip it. Torch skips a session Claude Code marks as unattended, and these hosts: a headless run (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.
  4. Your first message archives it. It moves to .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.
  5. A first message of /torch:load-handoff or /load-handoff leaves the file to that skill.
  6. Old handoffs are announced, not loaded. If a handoff was saved more than 14 days ago, the banner says it's there and it stays put. The save time comes from the handoff's saved_at, or the file's modification time without it.
  7. A handoff committed to the repository is not loaded. Handoffs are meant to stay out of commits, so a tracked one may be someone else's or may have come with a clone. It also isn't loaded inside a git repository when git can't tell whether it is tracked. These checks do not prove who wrote a file; an untracked handoff from someone else can still load automatically.
  8. A large handoff is pointed to, not pasted. Torch includes a handoff of up to about 10,000 characters, to keep the session's context lean. Above that, Claude is told where to read the file. A handoff over 4 MiB is not loaded at all.
  9. A file without a # Handoff title, or a symbolic link, is not loaded. The banner says why.

What Torch sends, reads, runs and writes

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:

  • the handoff's text, or its path when it is too large to include
  • the handoff's path, and where it was archived
  • its save time
  • the git drift summary

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.

  • At session start: the session id, the folder you started in, and whether the session is new or cleared.
  • With each message: whether you sent it, or something else did, such as a background task's notification.
  • With your first message: only whether its text starts with /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.

  • At session start, a small record for the session in Claude Code's storage for Torch (~/.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.
  • With the first message you send, it tries to delete the session's record and, once it is gone, moves the handoff into <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.
  • If the store refuses a deletion, the record stays and nothing is moved; the cleanup at a later session start tries again once the record is more than 30 days old.

The skills run only when you or Claude invoke them:

  • /torch:save-handoff
  • runs date -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-ignore
  • writes handoff-before-clear.md at the project root
  • /torch:load-handoff
  • runs git rev-parse --show-toplevel, git branch --show-current, git rev-parse HEAD and git status --short
  • reads the handoff, or else one Torch couldn't finish archiving or the newest archived one, plus the project's CLAUDE.md and the files the handoff lists
  • moves it to the archive if you choose that

See PRIVACY.md for retention and contact details.

Requirements

  • Claude Code 2.1.287 or later, with mods allowed. Torch is tested with 2.1.292. An organization can turn off mods that users install; there, Torch's skills work but nothing loads by itself. Torch works on your project folder, so it is not for claude.ai chat or Cowork, and its skills say so and stop there.
  • macOS or Linux, with mkdir, mv, link and rm, which both have. Windows is not supported. Nothing else to install: no Python, no Node.js.
  • git, inside a git repository. Folders that aren't repositories work without git.

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

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.

The handoff contract

/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.

Troubleshooting

  • No banner at session start:
  • check that handoff-before-clear.md is at the project root and starts with a # Handoff title
  • a handoff saved more than 14 days ago, or one committed to the repository, gets a banner saying why it wasn't loaded
  • headless sessions never load it
  • run claude --version (2.1.287 or later), then /plugin: its tabs show 1 mod active · torch when Torch's mod has loaded
  • "torch: could not announce the handoff: …" or "could not deliver the handoff: …": start Claude Code with claude --debug, try again, and open an issue with the line and the debug log.
  • The handoff was archived but the work didn't happen: see example 3.

Uninstall

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.

Support and security

Report problems and ask questions in GitHub issues. Report security vulnerabilities privately, as SECURITY.md describes.

License

MIT

Source 2 files
hooks/torch.mjs 353 lines
1// 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}
353
hooks/rules.mjs 279 lines
1// 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