他セッションとの SendMessage の送受信を、プロンプト直上の帯・toast・status line に残す (transcript が流れても消えない写し)

対象は 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 122 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { InboxItem } from '../types'
5
6// 帯に出す件数と、溜めておく上限。
7const SHOWN = 3
8const KEPT = 50
9
10const items = atom({ plugin: 'peer-inbox', key: 'items' } as const, [] as InboxItem[])
11const isHidden = atom({ plugin: 'peer-inbox', key: 'isHidden' } as const, false)
12// 回数は帯の履歴 (上限 KEPT) と別に持つ (履歴から数えると上限を超えたところでずれる)。
13// 既読・未読は持たない: 目的は他セッションとの往来を本文 (メインの会話) の外に出すことで、読んだかどうかの管理は要らない
14const received = atom({ plugin: 'peer-inbox', key: 'received' } as const, 0)
15const sent = atom({ plugin: 'peer-inbox', key: 'sent' } as const, 0)
16
17// 他セッション / teammate との往来だけを対象にする (relay の通知・cron・Remote Control は除く)。
18const PEER_KINDS = new Set(['peer', 'coordinator', 'peer-send-message'])
19
20const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
21
22const clip = (text: string, width: number) =>
23 text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
24
25const hhmm = (at: number) => {
26 const d = new Date(at)
27 const p = (n: number) => String(n).padStart(2, '0')
28 return `${p(d.getHours())}:${p(d.getMinutes())}`
29}
30
31const countsText = (r: number, s: number) => `📨 受信 ${r}・送信 ${s}`
32
33// status line に回数を常に出す (往来が 1 回も無いうちは出さない)
34async function showCounts($: EngineInterface) {
35 const r = (await read($, received)) ?? 0
36 const s = (await read($, sent)) ?? 0
37 $.ui.status(r + s > 0 ? countsText(r, s) : undefined)
38}
39
40async function push($: EngineInterface, item: InboxItem, counted: boolean) {
41 await update($, items, list => [...(list ?? []), item].slice(-KEPT))
42 if (item.dir === 'in') {
43 await update($, isHidden, () => false)
44 }
45 // update の第 2 引数は atom をそのまま渡す (三項演算子で選ぶと、state の検査が読めず読み込みで止まる)
46 if (counted && item.dir === 'in') {
47 await update($, received, n => (n ?? 0) + 1)
48 } else if (counted) {
49 await update($, sent, n => (n ?? 0) + 1)
50 }
51 await showCounts($)
52}
53
54export const register: Register = on => {
55 // 読み直し (hot reload・再起動) の後も、数えた回数を status line に出し直す
56 on('session.start', async ($, e, next) => {
57 const r = await next(e)
58 await showCounts($)
59 return r
60 })
61
62 on('session.receive', async ($, e, next) => {
63 // subagent 宛 (e.agentId あり) は main の会話ではないので帯に出さない。
64 if (e.agentId === undefined && PEER_KINDS.has(e.origin.kind)) {
65 const who = 'teammate' in e.origin ? e.origin.teammate : e.origin.kind
66 const text = oneLine(e.text)
67 await push($, { dir: 'in', who, text, at: await $.clock.now() }, true)
68 $.ui.toast(`📨 ${who}: ${clip(text, 80)}`, { timeoutMs: 8000 })
69 }
70 return next(e)
71 }).catch(($, e, next) => (next.called ? next(e) : next(e)))
72
73 on('session.send', async ($, e, next) => {
74 if (e.agentId !== undefined) return next(e)
75 const r = await next(e)
76 // 送信の回数は届いたものだけを数える (帯には届かなかったものも残す)
77 await push($, { dir: 'out', who: e.to, text: oneLine(e.text), at: await $.clock.now() }, r.isDelivered)
78 return r
79 }).catch(($, e, next) => (next.called ? next(e) : next(e)))
80
81 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
82 const list = await read($, items)
83 if (e.props.hasSurvey || list.length === 0 || (await read($, isHidden))) {
84 return next(e)
85 }
86
87 const { Box, Button, Text } = $.ui.resolve(e)
88 const width = e.props.bodyColumns
89 const r = (await read($, received)) ?? 0
90 const s = (await read($, sent)) ?? 0
91 const shown = list.slice(-SHOWN)
92 const prefixWidth = 'HH:MM ⇦ '.length
93
94 return (
95 <Box flexDirection="column" width={width}>
96 <Box flexDirection="row" justifyContent="space-between">
97 <Text bold color="subtle">
98 📨 他セッション 受信 {r}・送信 {s}
99 </Text>
100 <Button key="hide" label="隠す" hotkey="h" onPress={() => update($, isHidden, () => true)} />
101 </Box>
102 {shown.map((i, n) => {
103 const arrow = i.dir === 'in' ? '⇦' : '⇨'
104 const whoWidth = Math.min(16, i.who.length)
105 const body = clip(i.text, Math.max(8, width - prefixWidth - whoWidth - 2))
106 return (
107 <Box key={`${i.at}-${n}`} flexDirection="row">
108 <Text dimColor>{hhmm(i.at)} </Text>
109 <Text color={i.dir === 'in' ? 'success' : 'claude'}>
110 {arrow} {clip(i.who, 16)}{' '}
111 </Text>
112 <Text wrap="truncate-end" dimColor={i.dir === 'out'}>
113 {body}
114 </Text>
115 </Box>
116 )
117 })}
118 </Box>
119 )
120 })
121}
122types/index.d.ts 13 lines1export type InboxItem = {
2 dir: 'in' | 'out'
3 who: string
4 text: string
5 at: number
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'peer-inbox': { items: InboxItem[]; isHidden: boolean; received: number; sent: number }
11 }
12}
13