他セッションの状態ペインと、制限に達したときのリセット案内バンド

Claude Code の mod(Function Hooks)。~/.claude/mods/session-hub/ に置かれ、claude/settings.json の env.CLAUDE_CODE_PLUGIN_DIRS が読み込む(install.sh が配置する)。
/sessions: 他セッションの一覧ペイン。セッション起動時に自動で開く(閉じたら /sessions で開き直す)。claude agents --json(生きているセッション・名前・種類)と ~/.claude/agent-status/*.json(状態。claude/agent-status.sh が書く)。/resume。バックグラウンドで動いているものは /resume できない(別プロジェクトも見つからない)ので、表示だけ。Sessions ボタンを出し、押すとペインを開く(狭い端末でも表示される)。5 時間 / 1 週間制限に達したときは、続けて確認つきの /limit-reset 案内を出す。 A(リセット権)と B(週の枠を使った 5h リセット)はどちらも /limit-reset で、どちらが動くかは Claude Code 側が決める。mod は案内の文言を変えるだけ。型は .claude-plugin/types/(エンジンが生成。git 管理外)にある。
claude plugin validate claude/mods/session-hub
claude plugin test claude/mods/session-hub
詳しい背景は features/src/macha-features/README.md の 「他セッションの状態表示」を参照。
hooks/register.tsx 295 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Gauge, Limit, Peer, ResetKind } from '../types'
5
6const PANE = 'sessions-pane'
7const gauge = atom({ plugin: 'session-hub', key: 'gauge' } as const, null)
8const tick = atom({ plugin: 'session-hub', key: 'tick' } as const, 0)
9const confirming = atom({ plugin: 'session-hub', key: 'confirming' } as const, null)
10
11// 現在のレート制限を取り直す (バンドで使う)
12const snapshot = async ($: any): Promise<Gauge> => ({ limits: (await $.session.usage()).rateLimits })
13
14const PEER_STYLE: Record<string, { color: string; icon: string }> = {
15 processing: { color: 'cyan', icon: '🔥' },
16 waiting_input: { color: 'yellow', icon: '✋' },
17 done: { color: 'green', icon: '🎉' },
18 error: { color: 'red', icon: '🚨' },
19}
20
21const ageLabel = (sec: number) =>
22 sec < 60 ? `${sec}s` : sec < 3600 ? `${Math.floor(sec / 60)}m` : `${Math.floor(sec / 3600)}h`
23
24// cwd からプロジェクト名を出す。worktree (<repo>/.claude/worktrees/<name>) は親リポジトリにまとめる
25const projectOf = (cwd?: string): string => {
26 if (!cwd) return '?'
27 const root = cwd.split('/.claude/worktrees/')[0]
28
29 return root.split('/').filter(Boolean).pop() ?? '?'
30}
31
32type AgentInfo = { name: string; kind?: string }
33
34// `claude agents --json` の生きているセッション。name は普段見ているタイトルで、agent-status の
35// name (hook が決める。最初の指示文などになりがち) より優先する。取れなければ空で、
36// agent-status の name を使う。
37const agentInfos = async ($: any): Promise<Map<string, AgentInfo>> => {
38 const infos = new Map<string, AgentInfo>()
39 try {
40 const { exitCode, stdout } = await $.process.run(['claude', 'agents', '--json'], { timeoutMs: 5000 })
41 if (exitCode === 0) {
42 for (const a of JSON.parse(stdout)) {
43 if (a.sessionId) infos.set(a.sessionId, { name: a.name, kind: a.kind })
44 }
45 }
46 } catch {
47 // claude が見つからない・出力が壊れているときは agent-status の name に任せる
48 }
49
50 return infos
51}
52
53// claude/agent-status.sh が書く他セッションの状態を読む。
54// statusline-command.sh と同じく、15 分止まったものと 60 秒を過ぎた done は除く (削除は向こうに任せる)。
55// 表示するセッションの上限。超えたら更新が新しい順に残す
56const MAX_PEERS = 5
57
58const loadPeers = async ($: any): Promise<Peer[]> => {
59 const self = await $.session.id()
60 const dir = `${await $.env.get('HOME')}/.claude/agent-status`
61 const now = Math.floor((await $.clock.now()) / 1000)
62 const entries = await $.fs.list(dir).catch(() => [])
63 const infos = await agentInfos($)
64 const peers: Peer[] = []
65
66 for (const { name } of entries) {
67 if (!name.endsWith('.json')) continue
68 try {
69 const j = JSON.parse(await $.fs.read(`${dir}/${name}`))
70 const ageSec = Math.max(0, now - (j.updated_at ?? 0))
71 // done は 60 秒で外す。更新が止まっているだけのものは、生きていれば出す。
72 // agents が取れないときだけ、生死が分からないので 15 分止まったものを外す
73 const isStale = (j.state === 'done' && ageSec > 60) || (infos.size === 0 && ageSec > 900)
74 // agents に載っていない (終了して resume もできない) セッションは、一覧が取れているときだけ除く
75 const isLive = infos.size === 0 || infos.has(j.session_id)
76 if (j.session_id && j.session_id !== self && !isStale && isLive && PEER_STYLE[j.state]) {
77 peers.push({ id: j.session_id, name: infos.get(j.session_id)?.name || j.name || '?',
78 kind: infos.get(j.session_id)?.kind, state: j.state, project: projectOf(j.cwd), ageSec })
79 }
80 } catch {
81 // 書き込み途中などで壊れたファイルは次回の読み込みに任せる
82 }
83 }
84
85 return peers.sort((a, b) => a.ageSec - b.ageSec).slice(0, MAX_PEERS)
86}
87
88type Group = { label: string; peers: Peer[] }
89
90// プロジェクトごとにまとめる (名前順)
91const groupPeers = (peers: Peer[]): Group[] =>
92 [...new Set(peers.map(p => p.project))]
93 .sort((a, b) => a.localeCompare(b))
94 .map(label => ({ label, peers: peers.filter(p => p.project === label) }))
95
96type Pick = (peer: Peer) => void
97
98// 名前を押すと、そのセッションへ切り替える。バックグラウンドで動いているものは
99// /resume できない (Claude Code が断る) ので、押せない表示だけにする。
100const PeerItem = ({ Box, Button, Text, peer, pick }: { Box: any; Button: any; Text: any; peer: Peer; pick: Pick }) => (
101 <Box>
102 <Text color={PEER_STYLE[peer.state].color}>{PEER_STYLE[peer.state].icon} </Text>
103 {peer.kind === 'background' ? (
104 <Text>{peer.name}</Text>
105 ) : (
106 <Button key={`peer-${peer.id}`} label={peer.name} onPress={() => pick(peer)} />
107 )}
108 <Text dimColor> {ageLabel(peer.ageSec)}</Text>
109 </Box>
110)
111
112const PeerGroup = ({ Box, Button, Text, g, pick }: { Box: any; Button: any; Text: any; g: Group; pick: Pick }) => (
113 <Box flexDirection="column">
114 <Text bold>{g.label}</Text>
115 <Box flexDirection="column" paddingLeft={2}>
116 {g.peers.map(peer => (
117 <PeerItem key={peer.id} Box={Box} Button={Button} Text={Text} peer={peer} pick={pick} />
118 ))}
119 </Box>
120 </Box>
121)
122
123// 中央で左右に分け、プロジェクトを 2 つずつ見出しの下に縦並びで出す
124const PeerGroups = ({ Box, Button, Text, groups, pick }: { Box: any; Button: any; Text: any; groups: Group[]; pick: Pick }) => {
125 const lines: Group[][] = []
126 for (let i = 0; i < groups.length; i += 2) lines.push(groups.slice(i, i + 2))
127
128 return (
129 <Box flexDirection="column">
130 {lines.map(line => (
131 <Box key={line[0].label} flexDirection="row">
132 {line.map(g => (
133 <Box key={g.label} width="50%">
134 <PeerGroup Box={Box} Button={Button} Text={Text} g={g} pick={pick} />
135 </Box>
136 ))}
137 </Box>
138 ))}
139 </Box>
140 )
141}
142
143// /resume <id> を打ったのと同じ動作で、このプロセスごと選んだセッションへ切り替える
144const switchTo = async ($: any, peer: Peer) => {
145 try {
146 await $.command.run({ command: 'resume', args: peer.id })
147 } catch (err) {
148 await $.ui.toast(`${peer.name} に切り替えられませんでした: ${String(err)}`)
149 }
150}
151
152// 制限に達したとみなす使用率
153const LIMIT_PERCENT = 100
154
155// 1 週間制限が先。そちらが尽きていれば 5 時間制限のリセットは意味が薄い
156const reachedLimit = (limits: Limit[]): ResetKind | null => {
157 const isHit = (kind: string) => limits.some(l => l.kind === kind && l.percentUsed >= LIMIT_PERCENT)
158
159 return isHit('seven_day') ? 'seven_day' : isHit('five_hour') ? 'five_hour' : null
160}
161
162// どちらも実体は /limit-reset。A (リセット権) か B (週の枠を使った 5h リセット) のどちらが動くかは
163// Claude Code 側が決める。ここで変えられるのは案内と確認の文言だけ。
164const RESET_TEXT: Record<ResetKind, { notice: string; button: string; ask: string }> = {
165 five_hour: {
166 notice: '5 時間制限に達しました · 週間の枠を少し使って 5h をリセットできます (週 1 回)',
167 button: 'リセットする',
168 ask: '週間の枠を少し使って 5 時間制限をリセットします。よろしいですか?',
169 },
170 seven_day: {
171 notice: '1 週間制限に達しました · リセット権があれば使えます',
172 button: 'リセット権を使う',
173 ask: 'リセット権を使って制限を回復します。よろしいですか?',
174 },
175}
176
177const openPane = async ($: any) => {
178 await $.ui.open({ id: PANE, title: 'Sessions' })
179 await update($, tick, n => n + 1)
180}
181
182const runReset = async ($: any) => {
183 await update($, confirming, () => null)
184 try {
185 await $.command.run({ command: 'limit-reset' })
186 } catch (err) {
187 await $.ui.toast(`リセットできませんでした: ${String(err)}`)
188 }
189}
190
191export const register: Register = on => {
192 let refresh: { cancel: () => void } | undefined
193
194 on('session.start', async ($, e, next) => {
195 // 他セッションの状態はファイルで変わるので、5 秒ごとに再描画する
196 refresh?.cancel()
197 refresh = $.clock.every(5000, () => void update($, tick, n => n + 1))
198
199 await $.command.register({
200 name: 'sessions',
201 description: '他セッションの状態ペインを開く',
202 })
203
204 // 起動時から開いておく
205 await $.ui.open({ id: PANE, title: 'Sessions' })
206
207 return next(e)
208 })
209
210 // レート制限が動くたびに gauge を更新し、読んでいる描画を再描画する
211 on('session.measure', async ($, e, next) => {
212 await update($, gauge, () => ({ limits: e.rateLimits }))
213
214 return next(e)
215 })
216
217 // ユーザーが入力したコマンドで開くので、狭い端末幅でも表示される
218 on('command.run', { command: 'sessions' }, async $ => {
219 await $.ui.open({ id: PANE, title: 'Sessions' })
220
221 return { text: 'ペインを開きました。' }
222 })
223
224 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
225 await read($, tick)
226 const { Box, Button, Text } = $.ui.resolve(e)
227 const peers = await loadPeers($)
228 const room = Math.max(1, (e.viewport?.rows ?? 24) - 6)
229
230 return (
231 <Box flexDirection="column">
232 {peers.length === 0 && <Text dimColor>他のセッションはありません。</Text>}
233 <PeerGroups
234 Box={Box}
235 Button={Button}
236 Text={Text}
237 groups={groupPeers(peers.slice(0, room))}
238 pick={peer => void switchTo($, peer)}
239 />
240 </Box>
241 )
242 })
243
244 // ペインが閉じたらバンドを描き直し、ボタンを戻す
245 on('ui.close', async ($, e, next) => {
246 const result = await next(e)
247 await update($, tick, n => n + 1)
248
249 return result
250 })
251
252 // ペインが出ていないときだけ、開くボタンを出す。制限に達したときは、続けて確認つきのリセット案内を出す。
253 // 押して開いたペインは、狭い端末でも表示される (起動時の自動オープンは 144 桁未満だと待機になる)
254 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
255 await read($, gauge) // session.measure での更新を購読するだけ。値は下で取り直す
256 await read($, tick)
257 const asked = await read($, confirming)
258
259 if (e.props.hasSurvey) {
260 return next(e)
261 }
262
263 const reached = reachedLimit((await snapshot($)).limits)
264 const { Box, Button, Text } = $.ui.resolve(e)
265 const text = reached && RESET_TEXT[reached]
266 const isOpen = (await $.ui.panes()).some((p: any) => p.id === PANE && p.isPlaced)
267
268 if (isOpen && !text) {
269 return next(e)
270 }
271
272 return (
273 <Box>
274 {!isOpen && (
275 <Button key="open-sessions" label="Sessions" onPress={() => void openPane($)} />
276 )}
277 {!isOpen && text && <Text> </Text>}
278 {text && asked === reached ? (
279 <Box>
280 <Text color="yellow">{text.ask} </Text>
281 <Button key="reset-yes" label="はい" onPress={() => void runReset($)} />
282 <Text> </Text>
283 <Button key="reset-no" label="やめる" onPress={() => update($, confirming, () => null)} />
284 </Box>
285 ) : text ? (
286 <Box>
287 <Text color="red">{text.notice} </Text>
288 <Button key="reset" label={text.button} onPress={() => update($, confirming, () => reached)} />
289 </Box>
290 ) : null}
291 </Box>
292 )
293 })
294}
295types/index.d.ts 13 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2export type Gauge = { limits: Limit[] }
3
4export type ResetKind = 'five_hour' | 'seven_day'
5
6export type Peer = { id: string; name: string; state: string; kind?: string; project: string; ageSec: number }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'session-hub': { gauge: Gauge | null; tick: number; confirming: ResetKind | null }
11 }
12}
13