Ends /handoff by clearing the session and running /takeoff.

My dotfiles and system configuration, declaratively managed with Nix.
| Layer | Tool |
|---|---|
| Config | Nix Flakes + Home Manager |
| WM | Niri |
| Terminal | Ghostty |
| Editor | Neovim (LazyVim) |
| Shell | Zsh + Starship |
nix/ # NixOS & Home Manager modules
config/ # Tool configs linked or seeded by Nix
bin/ # Scripts and utilities added to PATH
skills/ # Local agent skills linked into Claude, Pi, and Codex
Setting up a fresh Mac? Run bin/bootstrap-mac from the checkout for an interactive walkthrough, including adding a missing target. See the new Mac bootstrap guide for the initial clone and manual steps.
For this M1 Air's nix-darwin migration, Tailscale SSH, and closed-lid use, follow Mac remote access.
On the desktop, Z13, and M1, remote-mode on keeps the machine awake for remote access across logouts and reboots. Use remote-mode off to restore normal sleep behavior, or remote-mode status to check it. See remote mode for laptop precautions.
Use t3ctl to control T3 Code on obelisk from agents or scripts.
Private agent skills sync from Forgejo on opted-in personal hosts, outside this public checkout and the Nix store.
Use !fork-window in Claude or Codex, or /fork-window in Pi, to fork the current conversation into a Herdr tab or a new Ghostty window on Hyprland without a model turn in the original session. The fork shares the checkout, acknowledges a fork notice, and waits for instructions. From a terminal, use fork-window pi /path/to/session.jsonl, fork-window claude <session-uuid>, or fork-window codex <session-uuid>. Container sandboxes are not supported.
Use /spawn <task> in Claude to start a new session in a Herdr tab that begins work on a task from the current conversation. A fork of the conversation writes the task brief and picks the repo and worktree, then runs spawn-session, which opens the tab and starts the agent.
just switch # Apply config (NixOS, nix-darwin, or Home Manager)
just check # Format, lint, evaluatehooks/register.ts 70 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const STAY = '--stay'
4const HANDOFF_FILE = 'Handoff file:'
5
6// /handoff is a prompt command, so its work is the turn that starts after it runs.
7let startedAt: number | undefined
8let handoffTurn: string | undefined
9
10// Takeoff runs in this session after /clear, so the handoff lives at the session's
11// root, wherever the agent cd'd or whichever repo it worked in.
12async function handoffPath($: EngineInterface) {
13 const root = await $.session.root()
14 const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: root })
15 return `${exitCode === 0 ? stdout.trim() : root}/handoff.md`
16}
17
18// Clears only when this handoff wrote handoff.md, so a failed one keeps the session.
19async function takeOff($: EngineInterface, since: number) {
20 const path = await handoffPath($)
21 const isWritten = (await $.fs.exists(path)) && (await $.fs.stat(path)).mtimeMs >= since
22 if (!isWritten) {
23 $.ui.toast(`No new ${path}, so the session stays as it is.`)
24 return
25 }
26 try {
27 await $.command.run({ command: 'clear' })
28 await $.command.run({ command: 'takeoff', args: path })
29 } catch (error) {
30 $.ui.toast(`handoff stopped: ${error instanceof Error ? error.message : String(error)}`)
31 }
32}
33
34export const register: Register = on => {
35 // The old handoff goes first, so the agent writes a fresh one instead of editing it.
36 // `--stay` writes the handoff and keeps the session; the command never sees the flag.
37 // The command gets the path instead, so the agent writes where takeoff reads.
38 on('command.run', { command: 'handoff' }, async ($, e, next) => {
39 const path = await handoffPath($)
40 await $.process.run(['rm', '-f', '--', path])
41 const words = e.args.split(/\s+/).filter(word => word !== '')
42 const isStaying = words.includes(STAY)
43 startedAt = isStaying ? undefined : await $.clock.now()
44 handoffTurn = undefined
45 const goal = words.filter(word => word !== STAY).join(' ')
46 return next({ ...e, args: `${HANDOFF_FILE} ${path}\n\n${goal}`.trimEnd() })
47 }).catch(($, e, next) => next(e))
48
49 on('turn.start', ($, e, next) => {
50 if (startedAt !== undefined && handoffTurn === undefined) {
51 handoffTurn = e.turnId
52 }
53 return next(e)
54 })
55
56 on('turn.complete', async ($, e, next) => {
57 const result = await next(e)
58 if (e.turnId !== handoffTurn || startedAt === undefined) {
59 return result
60 }
61 const since = startedAt
62 startedAt = handoffTurn = undefined
63 if (e.reason === 'answer') {
64 // A hook the turn waits on may not run commands, so the chain starts after it.
65 $.clock.after(0, () => void takeOff($, since))
66 }
67 return result
68 })
69}
70