SLOPSHOPPER

session-usage

週の利用上限を、どのセッションが使ったかをグラフで表示(起動すると画面の右に自動で開く)

newpanebandcommandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-usage
│ ┃ Session usage ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Session usage · this week ⏺ Read(src/auth.ts) │ ┃ │ ⎿ Read 6 lines │ ┃ │ Fri ██████████████████████████████████████ ⏺ Update(src/auth.ts) │ ┃ │ Sat ██████████████████████████████████████ ⎿ Added 2 lines, removed 1 line │ ┃ │ Sun ██████████████████████████████████████ ⏺ Bash(bun test) │ ┃ │ Mon ██████████████████████████████████████ ⎿ 3 pass, 1 fail │ ┃ │ Tue ██████████████████████████████████████ │ ┃ │ Wed ██████████ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ Thu │ ┃ │ 0h 6h 12h ✻ Worked for 42s · done 4:20 PM │ ┃ │ 18h │ ┃ │ No usage recorded yet. Each session adds › /session-usage │ ┃ │ its own after every reply. │ ┃ │ │ ┃ │ Shares are by cost, as /cost counts it. │ ┃ │ Sessions without this mod are not │ ┃ │ counted. │ ┃ ╰─────────────────────────────────────────── │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session usage
╭──────────────────────────────────────────────────────────╮ │ Session usage · this week │ │ │ │ Fri ████████████████████████████████████████████████ │ │ Sat ████████████████████████████████████████████████ │ │ Sun ████████████████████████████████████████████████ │ │ Mon ████████████████████████████████████████████████ │ │ Tue ████████████████████████████████████████████████ │ │ Wed ██████████ │ │ Thu │ │ 0h 6h 12h 18h │ │ No usage recorded yet. Each session adds its own after │ │ every reply. │ │ │ │ Shares are by cost, as /cost counts it. Sessions without │ │ this mod are not counted. │ ╰──────────────────────────────────────────────────────────╯
README

session-usage

Claude Code の mod(Claude Code の動きや画面を変えられるプラグイン) 週の利用上限を、どのセッションがどれだけ使ったかを、セッションをまたいで画面の右にグラフで表示

課題

週の利用上限に近づいても、どのセッションが使ったのかが分からない 作者の直近7日の記録を料金の比率で数えると、93セッションのうち上位5セッションで52%、下位半分の46セッションは合わせて0.4% 長く続けた数本のセッションが、気づかないうちに大半を使っていた

導入

Claude Code 2.1.287 以降が必要(claude --version で確認) 早期公開のときに CLAUDE_CODE_ENABLE_FUNCTION_HOOKS を設定していたら削除(2.1.287 以降は無視される、公式の mods の概要のページ)

  1. このリポジトリを marketplace(プラグインの配布元)として登録
   /plugin marketplace add shumatsumonobu/claude-mods-bench
  1. session-usage を入れる
   /plugin install session-usage@claude-mods-bench
  1. Claude Code を起動し直す ── 次の起動から有効、初回に作業フォルダの信頼を求められたら承認 開いたままのセッションなら、/reload-plugins でも読み込める(公式の mods の概要のページ)

動作

起動すると、画面の右に今週の使用量をセッション別に表示 上は曜日×時間帯の使用量で、使った時間帯ほど明るい緑 下はセッションごとの棒グラフで、作業フォルダ名・最初のプロンプト・割合・料金

セッション別の使用量

プロンプトの上の1行には、このセッションの割合と、7日の利用上限の消費率を表示 閉じたあとは、u(1行の右の u: Open)か /session-usage で開き直せる

棒グラフと割合の範囲は、7日の利用上限が前回リセットされてから今まで 上の色の表は、暦の上の直近7日 セッション同士は料金(/cost と同じ集計)で比べる

設計上の理由

mod は Claude Code の中で動くため、shell hook(settings.json に定義する従来のフック)では書けない次のことができる

  • 料金と利用上限を、返事のたびに読める ── mod は $.session.usage() で、セッションの料金と、5時間・7日の利用上限の消費率を読める モデルへの問い合わせは起きない shell hook の入力には、料金も利用上限の消費率も入っていない(公式の hooks のページで読んだ範囲)
  • 画面の右に開ける ── shell hook は画面に表示を描けない(公式の hooks のページ) mod なら ui.render で、画面の右に色の表と棒グラフを描き、プロンプトの上の1行にも出せる
  • セッションをまたいで集計できる ── mod の保存領域($.store)は、同じマシンで動く全セッションで共有される(公式の interface のページ) 各セッションが返事のたびに自分の料金を書き込み、どのセッションからでも全部を並べて見られる

仕様上の注意

実測(2026-10-07、Claude Code 2.1.289)と、公式ドキュメントを読んだ範囲

  • この mod 自体はトークンを使わない モデルを呼ばず、Claude が読む場所にも書かない /session-usage は画面の右を開くだけで、会話には何も返さない(ヘッドレスで実行して料金 0、1回)
  • 入れる前のセッションは数えない 記録は mod を入れてから、各セッションが自分で書き込む Claude Code のセッションの記録ファイルは、mod から読める大きさ(1ファイル4 MiB まで)を超えることが多いため、さかのぼって数えない(作者の直近7日では107件のうち28件が超え、最大408 MiB)
  • この mod を入れていないセッションは数えない ── 別のマシンや claude.ai での使用も入らない
  • セッションの割合は料金の比 ── 7日の利用上限の消費率はアカウント全体の数字のまま出し、セッションには割り振らない
  • サブエージェントの料金も、呼び出したセッションに入る ── セッションの料金はサブエージェントの分を含んでいた(ヘッドレスで1回)
  • 記録は8日で消す
  • 右の表示が自動で開くのは、端末の横幅が144文字以上のとき mod が自分で開く表示の制限(公式の interface のページ) /session-usage で開けば、どの幅でも表示
Source 1 files
hooks/register.ts 453 lines
1import type { On } from 'claude-code'
2
3/** 画面の右に開く表示の ID、コマンド名と `ui.render` での見分けにも使う */
4const PANE_ID = 'session-usage'
5/** 時間の長さ(ミリ秒) */
6const HOUR = 3600 * 1000
7const DAY = 24 * HOUR
8const WEEK = 7 * DAY
9/** 記録を残す期間、7日の窓より少し長く持ち、それより古いセッションの記録は消す */
10const KEEP_MS = 8 * DAY
11/** この時間内に返事があったセッションに、active の印を付ける */
12const ACTIVE_MS = 5 * 60 * 1000
13/** 棒グラフに出すセッションの数、残りは1行にまとめる */
14const TOP = 6
15
16/** セッション1つの記録、保存領域にセッションごとのキー(`s:<id>`)で置き、セッション同士の書き込みがぶつからないようにする */
17export type Rec = {
18  /** セッションの ID(`$.session.id()`) */
19  id: string
20  /** 作業フォルダの最後のディレクトリ名 */
21  project: string
22  /** 最初のプロンプトの先頭 */
23  title: string
24  /** 最後に返事が終わった時刻(ミリ秒) */
25  lastAt: number
26  /** `$.session.usage()` の料金(ドル)、セッションの累計 */
27  usd: number
28  /** 時間ごとの料金の増分、キーはその時間の始まり(ミリ秒)の文字列 */
29  hours: Record<string, number>
30}
31
32/** 利用上限1つの読み取り(`$.session.usage()` の `rateLimits` の1件) */
33export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
34
35/** このセッションの記録 */
36let me: Rec | null = null
37/** 保存領域から読んだ、全セッションの記録 */
38let all: Rec[] = []
39/** 最後に読んだ利用上限 */
40let limits: Limit[] = []
41/** 画面の右を開いているか */
42let isOpen = false
43/** ユーザーが自分で閉じたか、閉じたらこのセッションでは自動で開き直さない */
44let closedByUser = false
45/** ほかのセッションの記録を定期的に読み直すタイマーを、もう始めたか */
46let ticking = false
47
48/** パスの最後のディレクトリ名 */
49export function baseName(path: string): string {
50  const parts = path.replace(/\\/g, '/').replace(/\/+$/, '').split('/')
51  return parts[parts.length - 1] || path
52}
53
54/** 時刻が属する1時間の始まり(ミリ秒)を、記録のキーの文字列にする */
55export function hourKey(ms: number): string {
56  return String(Math.floor(ms / HOUR) * HOUR)
57}
58
59/**
60 * 料金の新しい累計を記録に足す
61 * 前の累計から増えた分だけを、今の時間に積み、記録を残す期間より古い時間は捨てる
62 */
63export function addCost(rec: Rec, usd: number, now: number): Rec {
64  const delta = usd - rec.usd
65  const hours: Record<string, number> = {}
66  for (const [k, v] of Object.entries(rec.hours)) if (Number(k) >= now - KEEP_MS) hours[k] = v
67  if (delta > 0) {
68    const k = hourKey(now)
69    hours[k] = (hours[k] ?? 0) + delta
70  }
71  return { ...rec, usd: Math.max(rec.usd, usd), lastAt: now, hours }
72}
73
74/**
75 * 数える範囲の始まり
76 * 7日の利用上限の次のリセットから7日さかのぼる、上限が読めなければ直近7日
77 */
78export function windowStart(lims: Limit[], now: number): number {
79  const week = lims.find((l) => l.kind === 'seven_day')
80  const reset = week?.resetsAt ? Date.parse(week.resetsAt) : NaN
81  return Number.isFinite(reset) && reset > now ? reset - WEEK : now - WEEK
82}
83
84/** 数える範囲の中で、そのセッションが使った料金 */
85export function costIn(rec: Rec, from: number): number {
86  let sum = 0
87  for (const [k, v] of Object.entries(rec.hours)) if (Number(k) + HOUR > from) sum += v
88  return sum
89}
90
91/**
92 * セッションを、数える範囲の中の料金が多い順に並べ、全体に占める割合を付ける
93 * 範囲の中で料金が無いセッションは外す
94 */
95export function ranking(recs: Rec[], from: number) {
96  const rows = recs.map((r) => ({ rec: r, usd: costIn(r, from) })).filter((x) => x.usd > 0)
97  const total = rows.reduce((s, x) => s + x.usd, 0)
98  rows.sort((a, b) => b.usd - a.usd)
99  return { total, rows: rows.map((x) => ({ ...x, share: total > 0 ? x.usd / total : 0 })) }
100}
101
102/** 1文字を8段階に分けて描くための、端数の文字 */
103const BLOCKS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
104
105/** 割合を、幅 width 文字の棒にする(1文字を8段階に分ける) */
106export function bar(share: number, width: number): string {
107  const eighths = Math.round(Math.max(0, Math.min(1, share)) * width * 8)
108  return '█'.repeat(Math.floor(eighths / 8)) + BLOCKS[eighths % 8]
109}
110
111/** 割合を百分率の文字にする、10% 未満は小数1桁まで */
112export function pct(share: number): string {
113  return `${(share * 100).toFixed(share < 0.1 ? 1 : 0)}%`
114}
115
116/**
117 * 曜日×時間帯の料金
118 * 行は6日前から今日までの7日、列は0時から23時(端末の時刻)
119 */
120export function heat(recs: Rec[], now: number): number[][] {
121  const grid = Array.from({ length: 7 }, () => new Array<number>(24).fill(0))
122  const today = new Date(now)
123  today.setHours(0, 0, 0, 0)
124  const first = today.getTime() - 6 * DAY
125  for (const r of recs) {
126    for (const [k, v] of Object.entries(r.hours)) {
127      const t = Number(k)
128      if (t < first) continue
129      const d = new Date(t)
130      const day = Math.round((new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() - first) / DAY)
131      if (day >= 0 && day < 7) grid[day][d.getHours()] += v
132    }
133  }
134  return grid
135}
136
137/** Raster で、端末の既定の色を表す値 */
138const DEFAULT_COLOR = 0x01000000
139/** マス目の色、使っていない時間は暗い灰色、多く使った時間ほど明るい緑 */
140const RAMP = [0x2b2b2b, 0x0e4429, 0x006d32, 0x26a641, 0x39d353]
141
142/** 料金を、色の段階(0 は使っていない、1〜4 は多いほど明るい)にする */
143export function level(v: number, max: number): number {
144  if (v <= 0 || max <= 0) return 0
145  return Math.min(4, 1 + Math.floor((v / max) * 3.999))
146}
147
148/** バイト列を base64 にする、`toBase64` が無い環境(テスト)では `btoa` で代わりにする */
149function toBase64(bytes: Uint8Array): string {
150  const u = bytes as any
151  if (typeof u.toBase64 === 'function') return u.toBase64()
152  let s = ''
153  for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
154  return btoa(s)
155}
156
157/**
158 * Raster に渡すマス目
159 * 1マスは文字・文字の色・背景の色の3つの数で、1時間を2文字の幅にする
160 */
161export function heatCells(grid: number[][]): { columns: number; rows: number; cells: string } {
162  const max = Math.max(0, ...grid.flat())
163  const nums: number[] = []
164  for (const row of grid) {
165    for (const v of row) {
166      const color = RAMP[level(v, max)]
167      for (let i = 0; i < 2; i++) nums.push('█'.codePointAt(0)!, color, DEFAULT_COLOR)
168    }
169  }
170  return { columns: 48, rows: grid.length, cells: toBase64(new Uint8Array(Uint32Array.from(nums).buffer)) }
171}
172
173/** 曜日の表示 */
174const WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
175
176/** マス目の左に並べる曜日、6日前から今日まで */
177function dayLabels(now: number): string[] {
178  return Array.from({ length: 7 }, (_, i) => WEEKDAY[new Date(now - (6 - i) * DAY).getDay()])
179}
180
181/** 7日の利用上限の消費率と、次のリセットの曜日と時刻 */
182function limitText(lims: Limit[]): string {
183  const week = lims.find((l) => l.kind === 'seven_day')
184  if (!week) return ''
185  const reset = week.resetsAt ? new Date(week.resetsAt) : null
186  const when = reset ? ` · resets ${WEEKDAY[reset.getDay()]} ${String(reset.getHours()).padStart(2, '0')}:${String(reset.getMinutes()).padStart(2, '0')}` : ''
187  return `7-day limit ${week.percentUsed}% used${when}`
188}
189
190/** 棒の上に出すセッションの名前 ── 作業フォルダ名、印(this session か active)、最初のプロンプト */
191function rowLabel(rec: Rec, isMe: boolean, now: number): string {
192  const tags: string[] = []
193  if (isMe) tags.push('this session')
194  else if (now - rec.lastAt < ACTIVE_MS) tags.push('active')
195  const title = rec.title ? ` · ${rec.title}` : ''
196  // 印は題より前に置く、題が長いと行の末尾が切れるため
197  return `${rec.project}${tags.length ? ` (${tags.join(', ')})` : ''}${title}`
198}
199
200/**
201 * 画面の右に出す中身
202 * 上に曜日×時間帯の使用量(端末だけ)、下にセッション別の棒グラフ、最後に7日の利用上限
203 */
204export function drawPane(t: any, surface: string, recs: Rec[], meId: string, lims: Limit[], now: number, columns: number) {
205  const { Box, Text, Raster } = t
206  const from = windowStart(lims, now)
207  const r = ranking(recs, from)
208  const barWidth = Math.max(8, Math.min(28, columns - 16))
209  const children: any[] = []
210
211  if (surface === 'terminal' && Raster) {
212    const h = heatCells(heat(recs, now))
213    children.push(
214      Box({
215        key: 'heat',
216        flexDirection: 'row',
217        gap: 1,
218        children: [
219          Box({ key: 'days', flexDirection: 'column', children: dayLabels(now).map((d, i) => Text({ key: `d${i}`, dimColor: true, children: d })) }),
220          Raster({ key: 'grid', ...h }),
221        ],
222      }),
223      Text({ key: 'hours', dimColor: true, children: '    0h          6h          12h         18h' }),
224    )
225  }
226
227  if (r.rows.length === 0) {
228    children.push(Text({ key: 'empty', dimColor: true, children: 'No usage recorded yet. Each session adds its own after every reply.' }))
229  } else {
230    const top = r.rows.slice(0, TOP)
231    top.forEach((x, i) => {
232      const isMe = x.rec.id === meId
233      children.push(
234        Box({
235          key: `s${i}`,
236          flexDirection: 'column',
237          marginTop: i === 0 ? 1 : 0,
238          children: [
239            Text({ key: 'name', ...(isMe ? { bold: true } : {}), wrap: 'truncate-end', children: rowLabel(x.rec, isMe, now) }),
240            Box({
241              key: 'bar',
242              flexDirection: 'row',
243              gap: 1,
244              children: [
245                Text({ key: 'b', color: isMe ? 'cyan' : 'green', children: bar(x.share, barWidth).padEnd(barWidth, ' ') }),
246                Text({ key: 'p', children: `${pct(x.share).padStart(5)}  $${x.usd.toFixed(2)}` }),
247              ],
248            }),
249          ],
250        }),
251      )
252    })
253    const rest = r.rows.slice(TOP)
254    if (rest.length > 0) {
255      const share = rest.reduce((s, x) => s + x.share, 0)
256      children.push(Text({ key: 'rest', dimColor: true, children: `+ ${rest.length} more session${rest.length === 1 ? '' : 's'}  ${pct(share)}` }))
257    }
258  }
259
260  const limit = limitText(lims)
261  children.push(
262    Box({
263      key: 'foot',
264      flexDirection: 'column',
265      marginTop: 1,
266      children: [
267        ...(limit ? [Text({ key: 'limit', children: limit })] : []),
268        Text({ key: 'note', dimColor: true, children: 'Shares are by cost, as /cost counts it. Sessions without this mod are not counted.' }),
269      ],
270    }),
271  )
272
273  return Box({
274    flexDirection: 'column',
275    borderStyle: 'round',
276    borderColor: 'green',
277    paddingX: 1,
278    children: [Text({ key: 'title', bold: true, color: 'green', children: 'Session usage · this week' }), Box({ key: 'body', flexDirection: 'column', marginTop: 1, children })],
279  })
280}
281
282/** プロンプトの上の1行 ── このセッションの割合と、7日の利用上限、このセッションに料金がまだ無ければ出さない */
283export function drawLine(t: any, recs: Rec[], meId: string, lims: Limit[], now: number, onOpen: () => void) {
284  const r = ranking(recs, windowStart(lims, now))
285  const mine = r.rows.find((x) => x.rec.id === meId)
286  if (!mine) return null
287  const { Box, Text, Button } = t
288  const limit = lims.find((l) => l.kind === 'seven_day')
289  const parts = [`this session ${pct(mine.share)} of the week`, ...(limit ? [`7-day limit ${limit.percentUsed}%`] : [])]
290  return Box({
291    key: 'session-usage',
292    gap: 2,
293    paddingX: 1,
294    children: [
295      Text({ key: 'label', bold: true, color: 'green', children: `Session usage · ${parts.join(' · ')}` }),
296      Button({ key: 'open', label: 'Open', hotkey: 'u', plain: true, onPress: onOpen }),
297    ],
298  })
299}
300
301/** このセッションの記録を、保存領域の自分のキーに書く */
302async function save($: any) {
303  if (me) await $.store.set(`s:${me.id}`, me)
304}
305
306/** 保存領域から全セッションの記録と利用上限を読み直し、記録を残す期間より古いセッションは消す */
307async function load($: any) {
308  const now = await $.clock.now()
309  const keys: string[] = await $.store.keys()
310  const recs: Rec[] = []
311  for (const k of keys) {
312    if (k === 'limits') {
313      const v = await $.store.get(k)
314      if (Array.isArray(v)) limits = v
315      continue
316    }
317    if (!k.startsWith('s:')) continue
318    const v = (await $.store.get(k)) as Rec | undefined
319    if (!v || now - v.lastAt > KEEP_MS) {
320      await $.store.delete(k)
321      continue
322    }
323    recs.push(v)
324  }
325  all = recs
326}
327
328/**
329 * 画面の右を開く
330 * コマンドやボタンで開くときだけキーボードを渡し、自動で開くときはプロンプトへの入力を奪わない
331 */
332async function openPane($: any, asked: boolean) {
333  isOpen = true
334  await load($)
335  $.ui.invalidate('ui.render')
336  const pane = { id: PANE_ID, title: 'Session usage', closeOnEscape: true, columns: 56 }
337  await $.ui.open(asked ? { ...pane, focus: true } : pane)
338  $.ui.invalidate('ui.render')
339}
340
341/**
342 * セッションの始まり
343 * このセッションの記録を用意し、ほかのセッションの記録を読み、コマンドを登録して、画面の右を自動で開く
344 */
345export async function onStart($: any, e: any, next: any) {
346  const r = await next(e)
347  try {
348    const id = await $.session.id()
349    const now = await $.clock.now()
350    const u = await $.session.usage()
351    const saved = (await $.store.get(`s:${id}`)) as Rec | undefined
352    me = saved ?? { id, project: baseName(String(e.cwd ?? (await $.session.cwd()))), title: '', lastAt: now, usd: u?.cost?.usd ?? 0, hours: {} }
353    if (Array.isArray(u?.rateLimits) && u.rateLimits.length > 0) limits = u.rateLimits
354    await load($)
355    if (!ticking) {
356      ticking = true
357      // ほかのセッションの記録を取り込む、読むのは手元の保存領域だけで、モデルは呼ばない
358      $.clock.every(30_000, async () => {
359        await load($)
360        $.ui.invalidate('ui.render')
361      })
362    }
363  } catch {
364    // 記録の準備に失敗しても、セッションは止めない
365  }
366  await $.command.register({ name: PANE_ID, description: 'Session usage: which sessions used the most this week' })
367  // コマンドを待たずに開く、ほかのセッションの記録があるので、開いた時点で中身がある
368  if (!closedByUser) await openPane($, false).catch(() => {})
369  return r
370}
371
372/** 最初のプロンプトの先頭を、このセッションの題として記録する(プロンプトは書き換えない) */
373export async function onPrompt($: any, e: any, next: any) {
374  if (me && me.title === '' && typeof e.text === 'string' && e.text.trim() !== '') {
375    me = { ...me, title: e.text.trim().split('\n')[0].slice(0, 40) }
376    await save($).catch(() => {})
377  }
378  return next(e)
379}
380
381/** 返事が終わるたびに、料金の累計と利用上限を読み、増えた分を記録して描き直す */
382export async function onTurn($: any, e: any, next: any) {
383  const answer = await next(e)
384  try {
385    if (me) {
386      const u = await $.session.usage()
387      const now = await $.clock.now()
388      if (typeof u?.cost?.usd === 'number') me = addCost(me, u.cost.usd, now)
389      await save($)
390      if (Array.isArray(u?.rateLimits) && u.rateLimits.length > 0) {
391        limits = u.rateLimits
392        await $.store.set('limits', limits)
393      }
394      await load($)
395      $.ui.invalidate('ui.render')
396    }
397  } catch {
398    // 記録の失敗でターンは止めない
399  }
400  return answer
401}
402
403/** `/session-usage` ── 画面の右を開く */
404export async function onCommand($: any, e: any) {
405  closedByUser = false
406  await openPane($, true)
407  // 文字を返すと会話に入り Claude が読むので、何も返さず画面に描くだけにする
408  return {}
409}
410
411/** 画面の右に、自分の表示を描く */
412export async function onPane($: any, e: any, next: any) {
413  if (e.requestId !== PANE_ID) return next(e)
414  const now = await $.clock.now()
415  return drawPane($.ui.resolve(e), String(e.surface ?? 'terminal'), all, me?.id ?? '', limits, now, Number(e.props?.bodyColumns ?? 56))
416}
417
418/** プロンプトの上の1行を描く、ほかの mod の表示も消さずに並べる */
419export async function onBand($: any, e: any, next: any) {
420  const now = await $.clock.now()
421  const ours = drawLine($.ui.resolve(e), all, me?.id ?? '', limits, now, () => {
422    closedByUser = false
423    void openPane($, true)
424  })
425  const theirs = await next(e)
426  if (ours === null) return theirs
427  if (!theirs) return ours
428  // ほかの mod の表示を消さないよう、自分の1行の下に並べる
429  const { Box } = $.ui.resolve(e)
430  return Box({ flexDirection: 'column', children: [ours, theirs] })
431}
432
433/** 画面の右が閉じられたとき、ユーザーが閉じたなら、このセッションでは自動で開き直さない */
434export async function onClose($: any, e: any, next: any) {
435  if (e.id === PANE_ID) {
436    isOpen = false
437    if (e.origin?.kind === 'person') closedByUser = true
438  }
439  $.ui.invalidate('ui.render')
440  return next(e)
441}
442
443/** mod の入口 ── 各セッションの料金を記録し、セッション別の使用量を画面の右とプロンプトの上の1行に出す */
444export function register(on: On) {
445  on('session.start', onStart)
446  on('prompt.submit', onPrompt)
447  on('turn.complete', onTurn)
448  on('command.run', { command: 'session-usage' }, onCommand)
449  on('ui.render', { component: 'Pane' }, onPane)
450  on('ui.render', { component: 'AbovePrompt' }, onBand)
451  on('ui.close', onClose)
452}
453