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

対象は macOS のみ。Linux はサポート対象外です (2026-08-28 決定 / issue 133)。 Linux でも動きそうに見える箇所がありますが、意図的な対応ではありません (例: scripts/tmux_extract_popup.sh は pbcopy を無条件で呼び、zshlib/_fs_helpers.zsh の mount パースは macOS の出力形式のみを前提にしています)。CI も macOS runner で回します。
cd ~
git clone git@github.com:jiikko/dotfiles.git || git clone https://github.com/jiikko/dotfiles.git
cd dotfiles
./setup.sh
docs/README.md が索引。触る前に読む制約 (glogx の bubbletea、テーマ色、tmux の セッション永続化)、glogx の画面の仕様、tmux 周りの仕組み、nvim の棚卸しがある。 自作ツールの使い方は src/README.md から各プロジェクトの README へ。
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 の冒頭にある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)。
C-t。ペイン・ウィンドウ操作、popup、ウィンドウ名、Claude Code の作業状態表示repair / av1ify (av1c) / concatkernel-alloc-watch / Finder Quick Actionssrc/README.md から各プロジェクトの README へhooks/register.tsx 98 lines1// 長く走っている 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}
98hooks/elapsed.ts 53 lines1// 帯に出す判定と文面 (純粋関数)。
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)
53types/index.d.ts 9 lines1/** 走っている 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