SLOPSHOPPER

tool-elapsed

長く走っている tool 呼び出し (codex exec / swift test / make test …) の経過時間をプロンプトの上の帯に出し、5 分ごとに toast で知らせる

newbandguardtoasttimer
★ 2v0.1.0no licenseupdated 2026-10-07jiikko/dotfiles/_claude/mods/tool-elapsed
A shopper browsing a rack in a slop shop
README

dotfiles

対象は macOS のみ。Linux はサポート対象外です (2026-08-28 決定 / issue 133)。 Linux でも動きそうに見える箇所がありますが、意図的な対応ではありません (例: scripts/tmux_extract_popup.sh は pbcopy を無条件で呼び、zshlib/_fs_helpers.zsh の mount パースは macOS の出力形式のみを前提にしています)。CI も macOS runner で回します。

Installing

cd ~
git clone git@github.com:jiikko/dotfiles.git || git clone https://github.com/jiikko/dotfiles.git
cd dotfiles
./setup.sh

for Mac

設計文書・仕様・調査記録

docs/README.md が索引。触る前に読む制約 (glogx の bubbletea、テーマ色、tmux の セッション永続化)、glogx の画面の仕様、tmux 周りの仕組み、nvim の棚卸しがある。 自作ツールの使い方は src/README.md から各プロジェクトの README へ。

Git hooks

githooks/ を setup.sh が core.hooksPath に設定する。

  • pre-commit: ステージした差分に、成人向けを匂わせる語や作品番号の書式がないかを civility-lint (dotfiles の外にある private repo のツール) で検査する。本体が無いマシンでは警告だけ出して通す。 誤検出を 1 行だけ通すなら、その行に civility-lint:ignore を書く
  • pre-push: issues/ を触る push のときだけ、push する commit を展開して issue の整合検査 (番号の一意性・相対リンク・next の目印など tests/issues/ の 6 本。数秒) を回し、落ちたら止める。 今回の push が壊したものでなくても止まる。回す検査と外す検査の一覧は hook の冒頭にある

Testing

Run the regression test suite (Neovim, tmux, setup.sh, plus existing zsh tests) with:

make test runs lint and the tests all the way through and reports every failure at the end; a lint failure no longer stops the tests from running. Use make test-lint when you only want lint.


make test

You can run individual checks as well:

make test-syntax # zsh/zlogin/setup.sh syntax checks + tmux/nvim smoke
make test-nvim   # verifies Neovim config loads and lazy.nvim is reachable
make test-tmux   # ensures _tmux.conf can boot a tmux server (skips if tmux sockets are disallowed)
make test-setup  # exercises setup.sh in a temporary HOME
make test-shellcheck # runs shellcheck on shell-compatible scripts
make test-yaml   # yamllint on workflow/pre-commit config
make test-json   # jq validation for JSON configs
make test-lint   # aggregate lint target (shellcheck + zsh syntax + YAML + JSON + karabiner + actionlint + gitconfig + ruby syntax + the repo-wide scripts/check_*.sh gates; full list in the Makefile)
make test-src    # lint + unused check + test for all Go projects under src/ (same coverage as CI's src_*.yml)
make test-runtime # aggregate runtime target (syntax + auto-discovered tests/**/test_*.sh + bats)
make test-bats   # bats tests (skips if bats is not installed)
tests/zshrc/test_zshrc.sh  # existing zsh tests (also run via make test)

ツールの使い方

自作ツールの使い方は docs/tools/ に置いている (索引は docs/README.md)。

  • tmux のキーと表示 — prefix は C-t。ペイン・ウィンドウ操作、popup、ウィンドウ名、Claude Code の作業状態表示
  • 動画のシェル関数 — repair / av1ify (av1c) / concat
  • macOS 連携 — Karabiner-Elements / kernel-alloc-watch / Finder Quick Actions
  • Go で書いた自作ツール (glogx・pro-con など) — src/README.md から各プロジェクトの README へ
Source 3 files
hooks/register.tsx 98 lines
1// 長く走っている tool 呼び出しの経過時間を、プロンプトの上の帯に出す。
2// 走っている呼び出しの表は module の変数 (Map) に持つ: tool.call の前後と 1 秒の timer が同じ worker で同期に読み書きするので、
3// $.state の CAS (update) で競合させない (red team: 並列の終了で cleanup が失敗して記録が残る / 空の snapshot で timer を止める /
4// 重なった tick が同じ toast を 2 回出す、の 3 件が全部その競合から出た)。hot reload で module が作り直されると await 中の
5// 記録は消えるが、その呼び出しの finally も走らないので、残骸は出ない。$.state は帯を描き直す合図 (tick) だけ。
6// 5 分ごとに toast (「codex review: 10 分経過」) も出す。待ちの上限を人が判断する材料で、何かを止めはしない。
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register, Timer } from 'claude-code'
9
10import { SHOW_AFTER_MS, clip, dueToast, formatElapsed, labelOf, visible } from './elapsed'
11import type { Run } from '../types'
12
13const tick = atom({ plugin: 'tool-elapsed', key: 'tick' } as const, 0)
14
15const runs = new Map<string, Run>()
16let timer: Timer | null = null
17let ticking = false
18
19async function onTick($: EngineInterface): Promise<void> {
20  if (ticking) return // 前の周がまだ終わっていない (toast の 2 重出しを防ぐ)
21  ticking = true
22  try {
23    if (runs.size === 0) {
24      await update($, tick, n => (n ?? 0) + 1) // 最後の 1 本が終わった帯を空に描き直してから止める
25      timer?.cancel()
26      timer = null
27      return
28    }
29    const nowMs = await $.clock.now()
30    for (const r of runs.values()) {
31      const due = dueToast(r, nowMs)
32      if (due !== null) {
33        r.toasts = due.toasts // await の前に進める (重なった周が同じ節目を読まない)
34        $.ui.toast(due.text, { timeoutMs: 8000 })
35      }
36    }
37    await update($, tick, n => (n ?? 0) + 1) // 読んでいる帯を描き直す
38  } catch {
39    // $ の口が拒否されても timer を未処理の reject にしない (次の周でやり直す)
40  } finally {
41    ticking = false
42  }
43}
44
45function ensureTimer($: EngineInterface): void {
46  if (timer !== null) return
47  timer = $.clock.every(1000, () => {
48    void onTick($)
49  })
50}
51
52export const register: Register = on => {
53  on('session.start', ($, e, next) => {
54    runs.clear()
55    return next(e)
56  })
57
58  on('tool.call', async ($, e, next) => {
59    const nowMs = await $.clock.now()
60    const id = e.tool_use_id ?? `${e.tool}-${nowMs}-${Math.random().toString(36).slice(2, 8)}`
61    // tool の入力は envelope の直下に展開されている (e.command 等)
62    runs.set(id, { id, tool: e.tool, label: labelOf(e.tool, e), startedAt: nowMs, agentId: e.agentId, toasts: 0 })
63    ensureTimer($)
64    try {
65      return await next(e)
66    } finally {
67      runs.delete(id)
68    }
69  }).catch(($, e, next) => (next.called ? next(e) : next(e)))
70
71  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72    await read($, tick) // 1 秒ごとの更新を購読する
73    const nowMs = await $.clock.now()
74    const shown = visible(runs.values(), nowMs)
75    if (e.props.hasSurvey || shown.length === 0) return next(e)
76
77    const { Box, Text } = $.ui.resolve(e)
78    const width = e.props.bodyColumns
79    return (
80      <Box flexDirection="column" width={width}>
81        <Text bold color="warning">
82          ⏳ 実行中 {shown.length} 件 ({formatElapsed(SHOW_AFTER_MS)} 以上)
83        </Text>
84        {shown.map(r => {
85          const elapsed = formatElapsed(nowMs - r.startedAt)
86          const sub = r.agentId !== undefined ? ' (subagent)' : ''
87          return (
88            <Box key={r.id} flexDirection="row">
89              <Text color="warning">{elapsed} </Text>
90              <Text wrap="truncate-end">{clip(`${r.label}${sub}`, Math.max(8, width - elapsed.length - 2))}</Text>
91            </Box>
92          )
93        })}
94      </Box>
95    )
96  })
97}
98
hooks/elapsed.ts 53 lines
1// 帯に出す判定と文面 (純粋関数)。
2import type { Run } from '../types'
3
4/** これより短い呼び出しは帯に出さない (普通の Bash は数秒で終わる。出すのは「待っている」と感じる長さだけ) */
5export const SHOW_AFTER_MS = 20_000
6/** この間隔で toast を出す (1 本の呼び出しごとに 5 分・10 分・15 分 …) */
7export const TOAST_EVERY_MS = 5 * 60_000
8
9/** 帯の 1 行に使う名前。Bash は description があればそれ、無ければ command の先頭 */
10export function labelOf(tool: string, input: unknown): string {
11  if (tool === 'Bash' && input !== null && typeof input === 'object') {
12    const i = input as { description?: unknown; command?: unknown }
13    const d = typeof i.description === 'string' ? i.description.trim() : ''
14    if (d !== '') return `Bash: ${d}`
15    const c = typeof i.command === 'string' ? i.command.replace(/\s+/g, ' ').trim() : ''
16    if (c !== '') return `Bash: ${c}`
17  }
18  if (tool === 'Agent' && input !== null && typeof input === 'object') {
19    const i = input as { description?: unknown; subagent_type?: unknown }
20    const d = typeof i.description === 'string' ? i.description.trim() : ''
21    const t = typeof i.subagent_type === 'string' ? i.subagent_type : ''
22    return `Agent${t !== '' ? ` (${t})` : ''}${d !== '' ? `: ${d}` : ''}`
23  }
24  return tool
25}
26
27/** 経過時間: mm:ss、1 時間を超えたら h:mm:ss */
28export function formatElapsed(ms: number): string {
29  const s = Math.max(0, Math.floor(ms / 1000))
30  const p2 = (n: number) => String(n).padStart(2, '0')
31  const h = Math.floor(s / 3600)
32  const m = Math.floor((s % 3600) / 60)
33  const sec = s % 60
34  return h > 0 ? `${h}:${p2(m)}:${p2(sec)}` : `${p2(m)}:${p2(sec)}`
35}
36
37/** 帯に出す呼び出し: SHOW_AFTER_MS 以上走っているもの。長い順 */
38export function visible(runs: Iterable<Run>, nowMs: number): Run[] {
39  return [...runs]
40    .filter(r => nowMs - r.startedAt >= SHOW_AFTER_MS)
41    .sort((a, b) => a.startedAt - b.startedAt)
42}
43
44/** 次の 5 分の節目を越えていたら、その toast の文と、更新後の toasts。越えていなければ null */
45export function dueToast(r: Run, nowMs: number): { text: string; toasts: number } | null {
46  const next = r.toasts + 1
47  if (nowMs - r.startedAt < next * TOAST_EVERY_MS) return null
48  const minutes = Math.floor((nowMs - r.startedAt) / 60_000)
49  return { text: `⏳ ${clip(r.label, 60)}: ${minutes} 分経過`, toasts: Math.floor((nowMs - r.startedAt) / TOAST_EVERY_MS) }
50}
51
52export const clip = (text: string, width: number) => (text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text)
53
types/index.d.ts 9 lines
1/** 走っている tool 呼び出し 1 つ。toasts は「何回目の 5 分の toast まで出したか」 */
2export type Run = { id: string; tool: string; label: string; startedAt: number; agentId?: string; toasts: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'tool-elapsed': { tick: number }
7  }
8}
9