SLOPSHOPPER

peer-inbox

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

newbandtoaststatus
★ 2v0.1.0no licenseupdated 2026-10-09jiikko/dotfiles/_claude/mods/peer-inbox
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 2 files
hooks/register.tsx 122 lines
1import { 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}
122
types/index.d.ts 13 lines
1export 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