ディスクの空きを見張り、決めたラインを切ったら、各セッションに自分のリポジトリの片付けを知らせる

Claude Code に持たせる skill の置き場。
skill は、決まったコマンドを順に叩くスクリプトではなく、Claude がその場で判断して進める手順として書いている(スクリプトを持つのは restart-sessions で、後戻りできない連鎖と、読むだけの処理(見張り・会話ログの抜き出し)にとどめている)。実際に打つコマンドは、Claude Code のバージョン・権限の設定・CLAUDE.md などで変わる。うまくいかないときは、その場で指示を足すか調べさせる。
使うには、.agents/skills/<skill>/ を使う側のプロジェクトの skill ディレクトリ(Claude Code なら .claude/skills/)に置く。clone した先への symlink にすると、pull した更新がそのまま届く。写したなら、restart-sessions の config.local.json を使う側の .gitignore に足す(このリポジトリの .gitignore は効かない)。restart-sessions のコンテキストの見張りは、mod(mod/)を読み込ませれば各セッションが自分を見張る。mod を使わずに外から見張るなら、使う側の .claude/settings.json に SessionStart の hook を置く(restart-sessions の README)。heavy-task の hook も settings.json に置く(heavy-task の README)。
| skill | できること | 要るもの・実機確認 |
|---|---|---|
| restart-sessions | Herdr 上のセッションを、会話ログを引き継いで再起動する。SessionStart の hook を置いたプロジェクトで起動すると、コンテキストの使用率を見張り、知らせの出たセッションを確認なしで再起動する | Herdr。見張りには、使用率を出すステータスラインと config.local.json。実機確認は macOS・Windows 11 |
| weekly-pace | Claude と Codex の週次枠の使用ペースを概算する。restart-sessions が、Claude がハイペースのとき再起動した先に Codex へ作業を回すよう書き添えるのに使う | Claude 側は Herdr と、Weekly を出すステータスライン。Codex 側は codex CLI |
| heavy-task | 重い処理を始める前に CPU とメモリを測り、始めるか待つかを決める。hook が CPU の厳しいときに重いコマンドを止め、mod が空いたらセッションに知らせる | 何が重いかを書く、使う側の .claude/heavy-commands.json。hook と mod は Claude Code 用(mod は 2.1.289 の function hooks)。実機確認は macOS のみ |
| cleanup | ディスクのゴミの候補を調べてレポートし、選んだものだけ消す。mod が空きを見張り、ラインを切ったら各セッションに自分のリポジトリを片付けさせる | 削除まで通したのは Windows のみ。mod は Claude Code 用で、実機確認は macOS のみ |
| machine-setup | 新しいマシンにアプリ・ツールを入れ、設定ファイルを取り込む | Claude Code。最後まで通したのは Ubuntu(Docker)のみ |
各 skill の前提・仕組み・使い方は、リンク先の README にある。skill に手を入れるときの決まりは AGENTS.md にある。
hooks/register.ts 119 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// 各セッションがディスクの空きを測り、ラインを切ったら、自分のリポジトリを片付けるよう自分に知らせる。
4// 片付けるのは持ち主のセッション自身なので、使用中かどうかを外から推し量らずに済む。
5// 知らせたリポジトリは、セッションをまたぐ $.store に持つ。同じリポジトリを開く複数のセッションのうち1つだけに知らせ、
6// 空きが戻るまでは、前に知らせたときより空きが 2GB 以上減って1時間たったときか、1日たったときだけ知らせ直す。
7// 時間だけで頻繁に知らせ直すと、空きを食っているのがリポジトリの外のとき、片付けのたびに Rust の dev の成果物が消えて作り直しになる。
8
9const GB = 1024 ** 3
10const DEFAULT_MIN_FREE_GB = 20
11const DEFAULT_RECOVER_GB = 30
12const FIRST_CHECK_AFTER_MS = 60 * 1000
13const CHECK_EVERY_MS = 10 * 60 * 1000
14export const RENOTIFY_AFTER_MS = 24 * 60 * 60 * 1000
15// 片付けた後も空きが減り続けるときは、新しく溜まった分を早めに片付けさせる。間を1時間置くのは、ビルドの途中の一時的な減りで続けて知らせないため。
16export const RENOTIFY_ON_DROP_AFTER_MS = 60 * 60 * 1000
17// ビルド1回分の揺れでは知らせ直さない大きさ。
18export const RENOTIFY_ON_DROP_GB = 2
19const NOTIFIED_KEY = 'notified'
20
21export type DiskWatchConfig = { minFreeGB: number; recoverGB: number; sharedCachesRepo: string | undefined }
22
23// 設定は config.local.json の diskWatch。無い・読めない値は既定値。recoverGB は minFreeGB より小さくしない。
24export function configOf(text: string | undefined): DiskWatchConfig {
25 let raw: { minFreeGB?: unknown; recoverGB?: unknown; sharedCachesRepo?: unknown } = {}
26 try {
27 raw = JSON.parse(text ?? '{}')?.diskWatch ?? {}
28 } catch {}
29 const positive = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : d)
30 const minFreeGB = positive(raw.minFreeGB, DEFAULT_MIN_FREE_GB)
31 const recoverGB = Math.max(positive(raw.recoverGB, DEFAULT_RECOVER_GB), minFreeGB)
32 const sharedCachesRepo = typeof raw.sharedCachesRepo === 'string' && raw.sharedCachesRepo !== '' ? raw.sharedCachesRepo : undefined
33 return { minFreeGB, recoverGB, sharedCachesRepo }
34}
35
36// `df -kP` の2行目の4列目(空きの KiB)。
37export function freeBytesOfDf(stdout: string) {
38 const fields = stdout.trim().split('\n')[1]?.trim().split(/\s+/)
39 const kib = Number(fields?.[3])
40 return Number.isFinite(kib) ? kib * 1024 : null
41}
42
43async function skillDir($: EngineInterface) {
44 const { realPath } = await $.fs.stat($.plugin.root, { resolve: true })
45 return (realPath ?? $.plugin.root).replace(/[\\/]mod[\\/]?$/, '')
46}
47
48// ホームのあるボリュームの空き。macOS / Linux は df、Windows は PowerShell。測れなければ null。
49// Windows では df を使わない。uutils の df は、パスを渡しても最初のボリューム(EFI の領域など)を返すことがあるため。
50async function measureFree($: EngineInterface) {
51 if ((await $.env.get('OS')) === 'Windows_NT') {
52 const ps = await $.process
53 .run(['powershell', '-NoProfile', '-Command', '(Get-Item $env:USERPROFILE).PSDrive.Free'])
54 .catch(() => undefined)
55 const out = ps?.stdout.trim()
56 const free = Number(out)
57 return ps?.exitCode === 0 && out && Number.isFinite(free) ? free : null
58 }
59 const home = (await $.env.get('HOME')) ?? (await $.session.cwd())
60 const df = await $.process.run(['df', '-kP', home]).catch(() => undefined)
61 return df?.exitCode === 0 ? freeBytesOfDf(df.stdout) : null
62}
63
64// リポジトリの元の clone のパス。worktree からも同じになるので、同じリポジトリのセッションを1つにまとめられる。
65async function repoRoot($: EngineInterface) {
66 const { exitCode, stdout } = await $.process.run(['git', 'rev-parse', '--path-format=absolute', '--git-common-dir'])
67 if (exitCode !== 0) return null
68 return stdout.trim().replace(/[\\/]\.git[\\/]?$/, '') || null
69}
70
71// 前の版は知らせた時刻だけを数で持っていた。その形は空きが分からないので、時間だけで判断する。
72type Notified = number | { at: number; free: number }
73
74export function shouldNotify(last: Notified | undefined, now: number, free: number) {
75 if (last === undefined) return true
76 const { at, free: lastFree } = typeof last === 'number' ? { at: last, free: undefined } : last
77 if (now - at >= RENOTIFY_AFTER_MS) return true
78 return lastFree !== undefined && now - at >= RENOTIFY_ON_DROP_AFTER_MS && lastFree - free >= RENOTIFY_ON_DROP_GB * GB
79}
80
81export async function check($: EngineInterface) {
82 const free = await measureFree($)
83 if (free === null) return
84 const dir = await skillDir($)
85 const text = await $.fs.read(`${dir}/config.local.json`).catch(() => undefined)
86 const config = configOf(typeof text === 'string' ? text : undefined)
87 const notified = ((await $.store.get(NOTIFIED_KEY)) ?? {}) as Record<string, Notified>
88
89 if (free >= config.recoverGB * GB) {
90 if (Object.keys(notified).length > 0) await $.store.set(NOTIFIED_KEY, {})
91 return
92 }
93 if (free >= config.minFreeGB * GB) return
94
95 const root = await repoRoot($)
96 if (root === null) return
97 const now = await $.clock.now()
98 if (!shouldNotify(notified[root], now, free)) return
99 await $.store.set(NOTIFIED_KEY, { ...notified, [root]: { at: now, free } })
100
101 const name = root.split(/[\\/]/).pop()
102 const shared = name === config.sharedCachesRepo ? ' このリポジトリは共有のキャッシュの担当なので、それも片付ける。' : ''
103 await $.prompt.submit({
104 text: `[ディスクの見張り] 空きが ${(free / GB).toFixed(1)}GB で、${config.minFreeGB}GB を切った。${dir}/self-clean.md に従い、ユーザーに聞かずにこのリポジトリ(${root})の中を片付ける。${shared}`,
105 })
106}
107
108export const register: Register = on => {
109 on('session.start', async ($, e, next) => {
110 const started = await next(e)
111 // 起動の直後はほかの処理と重なるので、少し置いてから1回測る。
112 void $.clock.sleep(FIRST_CHECK_AFTER_MS).then(() => check($))
113 $.clock.every(CHECK_EVERY_MS, () => {
114 void check($)
115 })
116 return started
117 })
118}
119