Passagem de bastão entre máquinas (/handoff) e aviso quando um turno longo termina

Live mods for Claude Code: see how long your plan will last, hand work off between machines, and give Claude eyes on Unity and Blender.
<img src="docs/terminal.svg" alt="Claude Code in a terminal: a band above the prompt shows the 5-hour limit at 38% lasting ~4.7h and the weekly limit at 41% lasting ~52h, three open sessions with tokens and cost, a model recommendation, and a Unity status line" width="100%">
Claude Code mods are plugins of function hooks: TypeScript that runs inside Claude Code and can draw panes and bands, add slash commands, watch tool calls, and feed context back to the model. This repository is a marketplace of five of them, built for daily use on a Max plan and on game projects.
| Mod | What it gives you |
|---|---|
| usage-limits | Your 5-hour and weekly limits with how long they will last at your current pace, tokens and cost across every open session, a per-project breakdown, API-credit tracking, and a model recommendation that keeps you from running dry before the reset. |
| fluxo | /handoff writes where you stopped so the next session on another machine picks it up, plus a sound and notification when a long turn finishes. |
| tarefas | The project's tasks in a plain TASKS.md: a pane, the next task above the prompt with Start and Done buttons, and Claude creating and finishing tasks as it works. |
| unity-tools | Compiler errors handed to Claude seconds after it edits a .cs, EditMode/PlayMode tests it can run on its own, a dedicated-server build pane, and guards that keep it out of Library/, scenes and .meta files. |
| blender-preview | A live thumbnail of the Blender scene with triangle, bone and texture budgets, and a check of every .glb Claude exports. |
[!NOTE] The mods' interface is in Brazilian Portuguese. Translations are very welcome; see Contributing.
You need a recent Claude Code (tested on 2.1.286 – 2.1.295). In a terminal, start claude and install the mods you want:
/plugin install usage-limits --marketplace kevinmedeiros/claude-mods
/plugin install fluxo --marketplace kevinmedeiros/claude-mods
/plugin install tarefas --marketplace kevinmedeiros/claude-mods
/plugin install unity-tools --marketplace kevinmedeiros/claude-mods
/plugin install blender-preview --marketplace kevinmedeiros/claude-mods
Answer y to add the marketplace the first time and pick the user scope. From then on the mods run in every Claude Code session on that machine: the terminal, the Code tab of Claude Desktop, and the IDE extensions. Restart sessions that were already open.
Update with /plugin update <mod>; change options with /plugin → the mod → configure.
<img src="docs/desktop-pane.svg" alt="The /limites pane in Claude Desktop: a 5-hour card at 38% with a bar, the projection hatched to the reset and a pace marker; a weekly card at 41% in red warning that at the current pace it runs out about 80 hours before the reset; and the session's token mix" width="560" align="right">
The percentages come from Anthropic's own rate-limit headers, so they cover the whole account: every session, every machine, and claude.ai. On top of them the mod answers the question the usage page doesn't: will this last until the reset?
/limites pane with bars that show used, projected-to-reset and an even-pace marker, plus the 5-hour pace, the weekly pace in points per day, and how many hours of work are left at the last hour's pace./economia on). The main thread keeps its model while new subagents run on Claude Haiku 5.5.| Option | Default | |
|---|---|---|
adminApiKey | — | sk-ant-admin… key of the Console organization that receives the plan's API credits. Kept in secure storage. |
apiMonthlyCredit | 200 | Max 5x: 100 · Max 20x: 200 |
apiCycleDay | 1 | Day of the month the credit renews |
Continue on another machine where you stopped.
/handoff [note] (or /passagem, if another plugin already has /handoff) has Claude Haiku 5.5 summarize the session (where you stopped, what was done, next steps, gotchas) and writes it to .claude/handoff.md with the files you changed and the git state.autoHandoff is on, every turn that edits files refreshes the file list, so there's a record even if the session dies.The handoff file travels with your project, through a git push or a synced folder. If you'd rather keep it out of git, add .claude/handoff.md to .gitignore and sync the folder instead.
| Option | Default |
|---|---|
longTurnMinutes | 3 |
sound / speak | true / false |
autoHandoff | false (turn on to refresh the file after every turn that edits) |
handoffPath | .claude/handoff.md |
The project's to-do list, kept in a TASKS.md at the project root (the same format as Claude's task-management skill, so you, Claude and your editor can all edit it).
/tarefas opens the pane: Active, Waiting On, Someday and recently done, a field to add a task, Start, ✓ and ↑ (move from Someday to Active)./tarefa <title> [- context] adds one from the prompt.task_add, task_done and task_list tools, so it records work it leaves for later and checks tasks off when it finishes them, with a toast every time. Turn this off with claudeCanEdit./tarefas importar pulls pending items from your notes (sections such as Pendente, Próximos passos or Next steps) and open checkboxes in BACKLOG.md / ROADMAP.md into Someday, without duplicates./handoff carries the open tasks to the other machine.| Option | Default |
|---|---|
tasksPath | TASKS.md |
showBand | true |
claudeCanEdit | true |
Turns on in any session whose folder holds a Unity project. It finds ProjectSettings/ above or up to two levels below the session's folder.
.cs, the mod compiles that assembly with dotnet build from the .csproj Unity generates, out of tree under Temp/, in about a second. The errors come back with the tool result. New files Unity hasn't listed yet are included./testes [editmode|playmode|tudo] runs the Unity Test Runner in batch mode and shows the results in the /unity pane, with a send failures to Claude button. Claude can also call the unity_tests tool itself to check its own changes./build-servidor builds the Linux dedicated server and streams the log. It tells you if the Linux Dedicated Server module is missing.Library/, Temp/, Logs/ or UserSettings/;.unity or .prefab files;.meta files by hand;UnityEngine inside an engine-free core folder.| Option | Default | |
|---|---|---|
projectPath | auto | Project folder, if auto-detection misses |
unityPath | Unity Hub path for the project's version | |
coreDir | Assets/Scripts/Core | Engine-free folder; empty turns the guard off |
compileCheck | true | |
allowSceneEdits | false | |
serverBuildPath | Builds/Server/Server.x86_64 | |
buildMethod | — | e.g. MyGame.Editor.BuildServer.Build; empty uses -buildLinux64Player |
Requirements:
dotnet), for the compile check.Works with Blender connected to Claude Code through a Blender MCP server.
/blender opens the pane..glb export, it reads the file and hands Claude a report: size, triangles, skin and bones, animations, and texture sizes, with a warning for anything over budget./glb <file> checks any .glb.| Option | Default |
|---|---|
maxTriangles | 20000 |
maxBones | 100 |
maxTextureSize | 2048 |
autoPreview | true |
server | Blender |
Everything stays on your machine.
~/.claude/usage-limits/, to each plugin's own store, to .claude/handoff.md and TASKS.md in your project.usage-limits calls the Console cost API, and only when you set an Admin key.fluxo calls Claude Haiku 5.5 when you run /handoff.unity-tools runs dotnet and the Unity editor; blender-preview talks to your local Blender through MCP.plugins/<mod>/
.claude-plugin/plugin.json manifest and options
hooks/register.tsx the hooks module
hooks/*.ts pure logic (unit-tested)
tests/*.test.ts run by `claude plugin test`
types/index.d.ts the mod's state contract
claude plugin validate plugins/usage-limits
claude plugin test plugins/usage-limits
claude --plugin-dir plugins/usage-limits
With --plugin-dir, a mod hot-reloads as you save. To regenerate the images in this README, run bun docs/generate-previews.ts; they are drawn with the mods' own code. See CONTRIBUTING.md.
MIT © Kevin Medeiros. Not affiliated with Anthropic.
hooks/register.tsx 257 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Handoff } from '../types'
5import {
6 SUMMARY_SYSTEM,
7 ago,
8 openTasks,
9 duration,
10 formatHandoff,
11 headline,
12 joinPath,
13 parseHandoff,
14 relative,
15 summaryPrompt,
16} from './handoff'
17import type { GitInfo } from './handoff'
18
19const PANE = 'fluxo-handoff'
20const SUMMARY_MODEL = 'claude-haiku-5-5'
21
22const incoming = atom({ plugin: 'fluxo', key: 'incoming' } as const, null)
23const edited = atom({ plugin: 'fluxo', key: 'edited' } as const, [])
24const summary = atom({ plugin: 'fluxo', key: 'summary' } as const, null)
25
26/** Onde a sessão está: raiz do projeto, nome dela e desta máquina. */
27const where = { root: '', project: '', machine: '', summaryAt: null as number | null, written: 0 }
28
29const run = async ($: EngineInterface, argv: string[], cwd: string) => {
30 try {
31 const r = await $.process.run(argv, { cwd, timeoutMs: 5000 })
32 return r.exitCode === 0 ? r.stdout.trim() : undefined
33 } catch {
34 return undefined
35 }
36}
37
38const gitInfo = async ($: EngineInterface, root: string): Promise<GitInfo> => {
39 const branch = await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], root)
40 if (branch === undefined) return null
41 const status = (await run($, ['git', 'status', '--porcelain'], root)) ?? ''
42
43 return {
44 branch,
45 dirty: status ? status.split('\n').length : 0,
46 lastCommit: (await run($, ['git', 'log', '-1', '--format=%s'], root)) ?? '',
47 }
48}
49
50/** Resume a conversa com Haiku; undefined quando não dá. */
51const summarize = async ($: EngineInterface, note: string) => {
52 const messages = await $.session.messages()
53 if (!Array.isArray(messages) || messages.length === 0) return undefined
54 const lines: string[] = []
55 for (const m of messages.slice(-80)) {
56 const tools = m.toolUses.map(t => t.tool).join(', ')
57 const text = m.text.length > 1500 ? `${m.text.slice(0, 1500)}…` : m.text
58 if (text || tools) lines.push(`[${m.role}] ${text}${tools ? ` (ferramentas: ${tools})` : ''}`)
59 }
60 let transcript = lines.join('\n')
61 if (transcript.length > 60_000) transcript = transcript.slice(-60_000)
62 const answer = await $.model.complete({
63 model: SUMMARY_MODEL,
64 system: SUMMARY_SYSTEM,
65 prompt: summaryPrompt(transcript, note),
66 maxTokens: 1500,
67 timeoutMs: 90_000,
68 })
69
70 return answer.isAnswered ? answer.text.trim() : undefined
71}
72
73/** As tarefas abertas do TASKS.md do projeto, se ele existir. */
74const tasksOf = async ($: EngineInterface) => {
75 const file = joinPath(where.root, 'TASKS.md')
76 try {
77 return (await $.fs.exists(file)) ? openTasks(await $.fs.read(file)) : undefined
78 } catch {
79 return undefined
80 }
81}
82
83/** Grava o handoff na raiz do projeto e devolve o caminho. */
84const writeHandoff = async ($: EngineInterface, path: string) => {
85 const at = await $.clock.now()
86 const text = formatHandoff({
87 project: where.project,
88 machine: where.machine,
89 at,
90 summary: await read($, summary),
91 summaryAt: where.summaryAt,
92 edited: await read($, edited),
93 git: await gitInfo($, where.root),
94 tasks: await tasksOf($),
95 })
96 const file = joinPath(where.root, path)
97 await $.fs.write(file, text)
98 where.written = (await read($, edited)).length
99
100 return file
101}
102
103/**
104 * Registra o comando com o primeiro nome livre: outro plugin pode já ter um
105 * `/handoff`. Uma recusa não derruba o resto do mod.
106 */
107const registerCommand = async (
108 $: EngineInterface,
109 names: string[],
110 spec: { description: string; argumentHint?: string },
111): Promise<string | undefined> => {
112 for (const name of names) {
113 try {
114 await $.command.register({ name, ...spec })
115 return name
116 } catch {
117 // Nome ocupado: tenta o próximo.
118 }
119 }
120 return undefined
121}
122
123/** Mostra o handoff de outra máquina, se houver um novo. */
124const loadIncoming = async ($: EngineInterface, path: string) => {
125 const file = joinPath(where.root, path)
126 if (!(await $.fs.exists(file))) return
127 const found = parseHandoff(await $.fs.read(file))
128 if (!found || found.machine === where.machine) return
129 const seen = await $.store.get(`seen:${where.root}`)
130 if (seen === found.at) return
131 await update($, incoming, () => found)
132}
133
134const dismiss = async ($: EngineInterface, handoff: Handoff) => {
135 await $.store.set(`seen:${where.root}`, handoff.at)
136 await update($, incoming, () => null)
137}
138
139/** O /handoff (ou /passagem): resume com Haiku e grava o arquivo. */
140const runHandoff = async ($: EngineInterface, args: string, handoffPath: string) => {
141 if (!where.root) return { text: 'Sem pasta de projeto nesta sessão.' }
142 const text = await summarize($, args.trim())
143 if (text) {
144 await update($, summary, () => text)
145 where.summaryAt = await $.clock.now()
146 }
147 const file = await writeHandoff($, handoffPath)
148
149 return {
150 text:
151 `Handoff salvo em ${file}${text ? '' : ' (sem resumo: a conversa não pôde ser resumida)'}.\n` +
152 'Leve o arquivo para a outra máquina junto com o projeto (commit e push, ou pasta sincronizada). ' +
153 'Lá, a próxima sessão no projeto mostra o handoff acima do prompt.',
154 }
155}
156
157export const register: Register = (on, options) => {
158 const handoffPath = String(options.handoffPath ?? '.claude/handoff.md')
159 const longTurnMs = Number(options.longTurnMinutes ?? 3) * 60_000
160
161 on('session.start', async ($, e, next) => {
162 const started = await next(e)
163 const top = await run($, ['git', 'rev-parse', '--show-toplevel'], started.cwd)
164 where.root = top || started.cwd
165 where.project = where.root.split(/[\\/]/).filter(Boolean).pop() ?? where.root
166 where.machine = ((await run($, ['hostname'], started.cwd)) ?? 'esta máquina').replace(/\.local$/, '')
167 await registerCommand($, ['handoff', 'passagem'], {
168 description: 'Resume onde você parou e grava em .claude/handoff.md para continuar em outra máquina',
169 argumentHint: '[observação opcional]',
170 })
171 await loadIncoming($, handoffPath)
172
173 return started
174 })
175
176 // Anota os arquivos que a sessão editou.
177 on('tool.call', async ($, e, next) => {
178 const ran = await next(e)
179 const path =
180 e.tool === 'Edit' || e.tool === 'Write'
181 ? e.file_path
182 : e.tool === 'NotebookEdit'
183 ? e.notebook_path
184 : undefined
185 if (path && !ran.deny && !ran.isError && where.root) {
186 const rel = relative(where.root, path)
187 await update($, edited, list => (list.includes(rel) ? list : [...list, rel].slice(-300)))
188 }
189
190 return ran
191 }).catch(($, e, next) => next(e))
192
193 on('turn.complete', async ($, e, next) => {
194 const result = await next(e)
195 if (e.agentId) return result
196
197 if (options.autoHandoff === true && where.root) {
198 const count = (await read($, edited)).length
199 if (count > where.written) await writeHandoff($, handoffPath)
200 }
201
202 if (e.durationMs >= longTurnMs && !e.isAborted) {
203 $.ui.toast(`✓ Turno terminou em ${duration(e.durationMs)}`, { timeoutMs: 10_000 })
204 if (options.sound !== false) void $.audio.play({ asset: 'sounds/done.wav' }).catch(() => undefined)
205 if (options.speak === true) void $.audio.speak('Claude terminou').catch(() => undefined)
206 }
207
208 return result
209 })
210
211
212 on('command.run', { command: 'handoff' }, ($, e) => runHandoff($, e.args, handoffPath))
213 on('command.run', { command: 'passagem' }, ($, e) => runHandoff($, e.args, handoffPath))
214
215 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
216 const { Markdown, Text } = $.ui.resolve(e)
217 const handoff = await read($, incoming)
218
219 return handoff ? <Markdown text={handoff.text.replace(/<!--[\s\S]*?-->\n?/, '')} /> : <Text dimColor>Nenhum handoff pendente.</Text>
220 })
221
222 // Acima do prompt: o handoff que chegou de outra máquina, sobre o que os outros plugins desenham.
223 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
224 const below = await next(e)
225 const handoff = await read($, incoming)
226 if (handoff === null || e.props.hasSurvey) return below
227 const { Box, Text, Button } = $.ui.resolve(e)
228 const now = await $.clock.now()
229
230 return (
231 <Box flexDirection="column">
232 <Box flexDirection="column">
233 <Text color="suggestion">
234 ↪ Handoff de {handoff.machine} ({ago(now - handoff.at)}): {headline(handoff)}
235 </Text>
236 <Box flexDirection="row" columnGap={2}>
237 <Button
238 key="fluxo-continue"
239 label="Continuar daqui"
240 variant="primary"
241 onPress={() => {
242 void $.prompt.fill({
243 text: `Leia ${handoffPath} e continue de onde a outra máquina parou.`,
244 })
245 void dismiss($, handoff)
246 }}
247 />
248 <Button key="fluxo-view" label="Ver" onPress={() => void $.ui.open({ id: PANE, title: 'Handoff' })} />
249 <Button key="fluxo-dismiss" label="Dispensar" plain onPress={() => void dismiss($, handoff)} />
250 </Box>
251 </Box>
252 {below}
253 </Box>
254 )
255 })
256}
257hooks/handoff.ts 136 lines1import type { Handoff } from '../types'
2
3export type GitInfo = { branch: string; dirty: number; lastCommit: string } | null
4
5export type HandoffInput = {
6 project: string
7 machine: string
8 at: number
9 summary: string | null
10 summaryAt: number | null
11 edited: string[]
12 git: GitInfo
13 /** As tarefas abertas do TASKS.md (mod tarefas), se houver. */
14 tasks?: string[]
15}
16
17/** As tarefas abertas das seções Active e Waiting On de um TASKS.md. */
18export const openTasks = (text: string): string[] => {
19 const out: string[] = []
20 let take = false
21 for (const line of text.split('\n')) {
22 const h = /^##\s+(.+?)\s*$/.exec(line)
23 if (h) {
24 take = /^(active|ativas|em andamento|waiting on|aguardando)$/i.test(h[1] ?? '')
25 continue
26 }
27 const m = /^[-*]\s+\[ \]\s+(.*)$/.exec(line)
28 if (take && m) out.push((m[1] ?? '').replace(/\*\*/g, '').trim())
29 }
30
31 return out
32}
33
34const HEADER = /<!--\s*fluxo-handoff machine="([^"]*)" at="([^"]*)"\s*-->/
35
36const pad = (n: number) => String(n).padStart(2, '0')
37
38export const stamp = (t: number) => {
39 const d = new Date(t)
40
41 return `${pad(d.getDate())}/${pad(d.getMonth() + 1)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
42}
43
44/** O arquivo de handoff, em markdown, com um cabeçalho que a outra máquina lê. */
45export const formatHandoff = (h: HandoffInput): string => {
46 const lines = [
47 `<!-- fluxo-handoff machine="${h.machine.replaceAll('"', "'")}" at="${new Date(h.at).toISOString()}" -->`,
48 `# Handoff · ${h.project} · ${h.machine} · ${stamp(h.at)}`,
49 '',
50 ]
51 if (h.summary) {
52 lines.push(h.summary.trim(), '')
53 if (h.summaryAt !== null && h.summaryAt < h.at - 60_000) {
54 lines.push(`_Resumo de ${stamp(h.summaryAt)}; houve trabalho depois dele._`, '')
55 }
56 } else {
57 lines.push('_Sem resumo: rode `/handoff` para gerar um._', '')
58 }
59 if (h.tasks && h.tasks.length > 0) {
60 lines.push('## Tarefas abertas (TASKS.md)')
61 lines.push(...h.tasks.slice(0, 20).map(t => `- [ ] ${t}`))
62 if (h.tasks.length > 20) lines.push(`- … e mais ${h.tasks.length - 20}`)
63 lines.push('')
64 }
65 lines.push('## Arquivos alterados nesta sessão')
66 lines.push(...(h.edited.length > 0 ? h.edited.slice(0, 60).map(f => `- ${f}`) : ['- nenhum']))
67 if (h.edited.length > 60) lines.push(`- … e mais ${h.edited.length - 60}`)
68 lines.push('', '## Git')
69 lines.push(
70 h.git
71 ? `Branch \`${h.git.branch}\` · ${h.git.dirty} arquivo${h.git.dirty === 1 ? '' : 's'} sem commit · último commit: ${h.git.lastCommit || '—'}`
72 : 'Pasta sem git: leve o arquivo junto com o projeto (pasta sincronizada ou cópia).',
73 )
74
75 return `${lines.join('\n')}\n`
76}
77
78/** Lê o cabeçalho de um handoff; undefined quando o arquivo não é um. */
79export const parseHandoff = (text: string): Handoff | undefined => {
80 const m = HEADER.exec(text)
81 if (!m) return undefined
82 const at = Date.parse(m[2] ?? '')
83
84 return { machine: m[1] ?? '?', at: Number.isNaN(at) ? 0 : at, text }
85}
86
87/** A primeira frase de "Onde parei", ou o título. */
88export const headline = (h: Handoff): string => {
89 const section = /##\s*Onde parei\s*\n+([^\n#]+)/.exec(h.text)
90 const line = section?.[1] ?? /^#\s+(.+)$/m.exec(h.text)?.[1] ?? ''
91
92 return line.trim().replace(/^[-*]\s+/, '').slice(0, 160)
93}
94
95export const ago = (ms: number): string => {
96 const minutes = Math.max(0, Math.round(ms / 60_000))
97 if (minutes < 60) return `há ${minutes}min`
98 const hours = Math.round(minutes / 60)
99 if (hours < 48) return `há ${hours}h`
100
101 return `há ${Math.round(hours / 24)} dias`
102}
103
104export const duration = (ms: number): string => {
105 const s = Math.round(ms / 1000)
106 if (s < 60) return `${s}s`
107 const m = Math.floor(s / 60)
108
109 return m < 60 ? `${m}min${s % 60 ? ` ${s % 60}s` : ''}` : `${Math.floor(m / 60)}h${pad(m % 60)}`
110}
111
112/** `/home/dev/my-game/client/x.cs` com raiz `/home/dev/my-game` → `client/x.cs`. */
113export const relative = (root: string, file: string): string => {
114 const norm = (p: string) => p.replaceAll('\\', '/').replace(/\/+$/, '')
115 const r = norm(root)
116 const f = norm(file)
117
118 return f.toLowerCase().startsWith(`${r.toLowerCase()}/`) ? f.slice(r.length + 1) : f
119}
120
121export const joinPath = (root: string, rel: string) =>
122 `${root.replace(/[\\/]+$/, '')}/${rel.replace(/^[\\/]+/, '')}`
123
124export const SUMMARY_SYSTEM =
125 'Você escreve passagens de bastão curtas, em português do Brasil, para alguém continuar um trabalho de ' +
126 'desenvolvimento em outra máquina. Seja concreto: nomes de arquivos, comandos, decisões. Sem introdução.'
127
128export const summaryPrompt = (transcript: string, note: string) =>
129 `Conversa da sessão (mais recente no fim):\n\n${transcript}\n\n` +
130 (note ? `Observação de quem pediu o handoff: ${note}\n\n` : '') +
131 'Escreva em markdown exatamente estas seções:\n' +
132 '## Onde parei\n(1 a 3 frases: o que estava em andamento e o estado atual)\n' +
133 '## Feito nesta sessão\n(até 6 tópicos)\n' +
134 '## Próximos passos\n(até 5 tópicos, em ordem)\n' +
135 '## Cuidados\n(só se houver: decisões tomadas, armadilhas, comandos que importam)'
136types/index.d.ts 21 lines1/** Um handoff lido do arquivo do projeto, escrito por outra máquina. */
2export type Handoff = {
3 machine: string
4 /** Quando foi escrito, em ms desde a época. */
5 at: number
6 text: string
7}
8
9declare module 'claude-code' {
10 interface PluginState {
11 fluxo: {
12 /** Handoff de outra máquina ainda não dispensado. */
13 incoming: Handoff | null
14 /** Arquivos editados nesta sessão, relativos à raiz do projeto. */
15 edited: string[]
16 /** O último resumo feito por /handoff nesta sessão. */
17 summary: string | null
18 }
19 }
20}
21