SLOPSHOPPER

session-hub

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

newpanebandcommandtoastprocess
v0.1.0no licenseupdated 2026-10-02macha434/dotfiles/claude/mods/session-hub
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-hub
│ ┃ Sessions ✕ › fix the failing auth test and add an audit log call │ ┃ 他のセッションはありません。 │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /sessions │ ⎿ session-hub: ペインを開きました。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Sessions
他のセッションはありません。
README

session-hub

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 が書く)。
  • プロジェクト(cwd のリポジトリ名、worktree は親にまとめる)ごとに最大 5 件、更新が新しい順。
  • 止まっているセッションの名前は押すと /resume。バックグラウンドで動いているものは /resume できない(別プロジェクトも見つからない)ので、表示だけ。
  • バンド(AbovePrompt): ペインが出ていないあいだ 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 の 「他セッションの状態表示」を参照。

Source 2 files
hooks/register.tsx 295 lines
1import { 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}
295
types/index.d.ts 13 lines
1export 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