SLOPSHOPPER

context-watch

restart-sessions のコンテキストの見張りを、セッションの間ずっと走らせ、知らせをセッションに渡す

newprocesstimer
A shopper browsing a rack in a slop shop
README

Yorozuya

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 一覧

skillできること要るもの・実機確認
restart-sessionsHerdr 上のセッションを、会話ログを引き継いで再起動する。SessionStart の hook を置いたプロジェクトで起動すると、コンテキストの使用率を見張り、知らせの出たセッションを確認なしで再起動するHerdr。見張りには、使用率を出すステータスラインと config.local.json。実機確認は macOS・Windows 11
weekly-paceClaude と 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 にある。

Source 2 files
hooks/register.ts 108 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { atom, read, update } from 'claude-code'
3
4import type { ContextWatchTold } from '../types'
5
6// 各セッションが自分を見張り、ターンの終わりに閾値を超えていれば、自分で再起動するよう自分に知らせる。
7// ターンの終わりは区切りなので、ほかのセッションが区切りを尋ねて返信を待つ仲介が要らない。
8// 知らせたかはセッションの状態に持つ。読み込み直し(mod の保存・更新)でも、同じ知らせを二度出さない。
9const told = atom({ plugin: 'context-watch', key: 'told' } as const, { over: false, update: null, idle: false } as ContextWatchTold)
10
11const DEFAULT_THRESHOLD = 50
12const GOAL_PATTERN = '/goal active'
13// 放置の目安は Claude Code の「放置から戻ったときのヒント」に合わせる(10万トークン以上で、最後の応答から75分。2.1.278 のコードで確認)。
14export const IDLE_AFTER_MS = 75 * 60 * 1000
15export const IDLE_MIN_TOKENS = 100_000
16const IDLE_CHECK_EVERY_MS = 60 * 1000
17const IDLE_RETRY_AFTER_MS = 10 * 60 * 1000
18
19// 最後に応答した時刻と、放置の /clear を見送った時刻。読み込み直すと初めからになり、待ちが延びるだけで済む。
20let lastAnswerAt = 0
21let idleSkippedAt = 0
22
23async function skillDir($: EngineInterface) {
24  const { realPath } = await $.fs.stat($.plugin.root, { resolve: true })
25  return `${realPath ?? $.plugin.root}/..`
26}
27
28// 閾値は config.local.json の watch.threshold。無い・読めなければ既定値。
29export function thresholdOf(text: string | undefined) {
30  try {
31    const value = Number(JSON.parse(text ?? '{}')?.watch?.threshold)
32    return Number.isFinite(value) && value > 0 ? value : DEFAULT_THRESHOLD
33  } catch {
34    return DEFAULT_THRESHOLD
35  }
36}
37
38// `claude --version` の出力(`2.1.290 (Claude Code)`)から版を取る。
39export const versionOf = (stdout: string) => stdout.trim().split(/\s+/)[0] || undefined
40
41// `/goal` が動いている間は知らせない(区切りの確かめで、目標追従を途中で止めることになるため)。
42async function isGoalActive($: EngineInterface, pane: string) {
43  const { exitCode, stdout } = await $.process.run(['herdr', 'pane', 'read', pane, '--source', 'visible'])
44  return exitCode === 0 && stdout.includes(GOAL_PATTERN)
45}
46
47async function check($: EngineInterface) {
48  const pane = await $.env.get('HERDR_PANE_ID')
49  if ((await $.env.get('HERDR_ENV')) !== '1' || pane === undefined) return
50  const dir = await skillDir($)
51  const guide = `${dir}/restart-self.md に従って、このセッション自身を再起動する。`
52  const current = await read($, told)
53
54  if (!current.over) {
55    const config = await $.fs.read(`${dir}/config.local.json`).catch(() => undefined)
56    const threshold = thresholdOf(typeof config === 'string' ? config : undefined)
57    const { percent } = (await $.session.usage()).context
58    if (percent !== undefined && percent >= threshold && !(await isGoalActive($, pane))) {
59      await update($, told, t => ({ ...t, over: true }))
60      await $.prompt.submit({ text: `[コンテキストの見張り] OVER ${percent}%(閾値 ${threshold}%)。${guide}` })
61      return
62    }
63  }
64
65  const { exitCode, stdout } = await $.process.run(['claude', '--version'])
66  const installed = exitCode === 0 ? versionOf(stdout) : undefined
67  const { version: running } = await $.session.version()
68  if (installed !== undefined && installed !== running && current.update !== installed && !(await isGoalActive($, pane))) {
69    await update($, told, t => ({ ...t, update: installed }))
70    await $.prompt.submit({ text: `[コンテキストの見張り] UPDATE ${running} → ${installed}。Claude Code の更新を反映するため、${guide}` })
71  }
72}
73
74// 放置されて大きくなったら、モデルのターンを使わずに自分のペインで /clear して続ける(idle-clear-self.mjs)。
75async function checkIdle($: EngineInterface) {
76  const pane = await $.env.get('HERDR_PANE_ID')
77  if ((await $.env.get('HERDR_ENV')) !== '1' || pane === undefined) return
78  const now = await $.clock.now()
79  if (now - lastAnswerAt < IDLE_AFTER_MS || now - idleSkippedAt < IDLE_RETRY_AFTER_MS) return
80  if ((await read($, told)).idle) return
81  const { tokens } = (await $.session.usage()).context
82  if (tokens === undefined || tokens < IDLE_MIN_TOKENS || (await isGoalActive($, pane))) return
83  const { exitCode } = await $.process.run(['node', `${await skillDir($)}/scripts/idle-clear-self.mjs`])
84  if (exitCode === 0) await update($, told, t => ({ ...t, idle: true }))
85  else idleSkippedAt = now
86}
87
88export const register: Register = on => {
89  on('session.start', async ($, e, next) => {
90    const started = await next(e)
91    lastAnswerAt = await $.clock.now()
92    $.clock.every(IDLE_CHECK_EVERY_MS, () => {
93      void checkIdle($)
94    })
95    return started
96  })
97
98  on('turn.complete', async ($, e, next) => {
99    const result = await next(e)
100    // サブエージェントのターンと、ユーザーが止めたターンでは見ない。
101    if (e.agentId === undefined && !e.isAborted) {
102      lastAnswerAt = await $.clock.now()
103      await check($)
104    }
105    return result
106  })
107}
108
types/index.d.ts 9 lines
1// over: OVER を知らせたか。update: UPDATE を知らせた先の版(まだなら null)。idle: 放置の /clear を送ったか。
2export type ContextWatchTold = { over: boolean; update: string | null; idle: boolean }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'context-watch': { told: ContextWatchTold }
7  }
8}
9