SLOPSHOPPER

overview

My Dotfiles!

newpanecommandprocesstimer
v?MITupdated 2026-10-080ta2/dots/dot_claude/mods/overview
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · overview
│ ┃ overview ✕ › fix the failing auth test and add an audit log call │ ┃ herdr の中で開いてください │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /overview │ ⎿ overview: Overview pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · overview
herdr の中で開いてください
README

dots

セットアップ (初回のみ、2段階)

1. chezmoi を導入し dots を取得する (反映はしない)

sh -c "$(curl -fsLS get.chezmoi.io)" -- -b $HOME/.local/bin init --source=~/ghq/github.com/0ta2/dots git@github.com:0ta2/dots.git

2. Homebrew と mise を用意して初回セットアップする

~/ghq/github.com/0ta2/dots/bootstrap.sh

Homebrew・mise を導入した後 mise run install (ドットファイル反映・不足ツール・skill・plugin の 導入・Codex 設定同期) を実行する。

日常のコマンド

mise run sync    # ドットファイル反映 + Codex設定マージ (副作用なし、何度実行しても収束する)
mise run update  # brew/mise/skill/uv tool の更新、最後に sync

直接 chezmoi コマンドを実行する場合は下記の通り:

~/.local/bin/chezmoi --source=~/ghq/github.com/0ta2/dots apply

mac セットアップ

  • 一般
  • 情報 
  • コンピュータ名 を変更
  • 自動入力とパスワード
  • パスワードやパスキーを自動入力 をOFF
  • アクセシビリティ
  • ポインタコントロール
  • トラックパッドオプション
  • ドラッグにトラックパッドを使用 をON
  • デスクトップとDock
  • Dock のサイズ小さく好みのサイズへ変更
  • Dock の拡大を好みの大きさへ変更
  • Dock を自動的に表示/非表示をOFF
  • 起動中のアプリケーションをアニメーションで表示をOFF
  • 起動済みのアプリケーションにインジケータを表示をOFF
  • アプリの提案と最近使用したアプリをDockに表示をOFF
  • アプリの提案と最近使用したアプリをDockに表示をOFF
  • 外観
  • 外観モードをダークに変更
  • キーボード
  • キーボードショートカット
  • Spotlight検索を表示 のショートカットのチェックを外す
  • 入力ソース
  • 前の入力ソースを選択 を Cmd + スペース に変更
  • 入力メニューの次のソースを選択 を Cmd + shift + スペース に変更
  • 装飾キー
  • CapsLock を Ctrl に変更する
  • 入力ソース
  • USキー と ひらがな(Google)
  • トラックパッド
  • ポイントとクリック
  • 軌跡の速さを一番早く
  • タップでクリック にチェック

Neovim ローカル開発プラグインのリンク作成

iwano.nvim / amanoukihashi.nvim をローカルのリポジトリから読み込むため、下記のシンボリックリンクを作成する:

mkdir -p ~/.local/share/nvim/site/pack/mine/opt
ln -s ~/ghq/github.com/0ta2/iwano.nvim ~/.local/share/nvim/site/pack/mine/opt/iwano.nvim-local
ln -s ~/ghq/github.com/0ta2/amanoukihashi.nvim ~/.local/share/nvim/site/pack/mine/opt/amanoukihashi.nvim-local
Source 3 files
hooks/register.tsx 215 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { agentStateOf, cards, type Board, type Main, type Status } from './board'
5
6const PANE = 'overview'
7const EMPTY: Board = { reply: [], working: [], review: [], done: [] }
8const board = atom({ plugin: 'overview', key: 'board' } as const, EMPTY)
9const isOpen = atom({ plugin: 'overview', key: 'isOpen' } as const, false)
10const POLL_MS = 30_000
11const FRESH_MS = 24 * 60 * 60 * 1000
12
13type Json = Record<string, unknown>
14type Pane = { pane: string; workspace: string; tabId: string }
15type Agent = { pane: string; tabId: string; name: string; status: string }
16
17const string = (value: unknown) => (typeof value === 'string' && value ? value : undefined)
18
19const result = (text: string): Json | undefined => {
20  try {
21    const value = JSON.parse(text) as { result?: unknown }
22    return value.result && typeof value.result === 'object' ? value.result as Json : undefined
23  } catch {
24    return undefined
25  }
26}
27
28const records = (text: string, key: string): Json[] => {
29  const value = result(text)?.[key]
30  return Array.isArray(value) ? value.filter((item): item is Json => !!item && typeof item === 'object') : []
31}
32
33const panesOf = (text: string): Pane[] =>
34  records(text, 'panes').flatMap(item => {
35    const pane = string(item.pane_id)
36    const workspace = string(item.workspace_id)
37    const tabId = string(item.tab_id)
38    return pane && workspace && tabId ? [{ pane, workspace, tabId }] : []
39  })
40
41const tabsOf = (text: string): { tabId: string; label: string }[] =>
42  records(text, 'tabs').flatMap(item => {
43    const tabId = string(item.tab_id)
44    return tabId ? [{ tabId, label: string(item.label) ?? string(item.name) ?? '' }] : []
45  })
46
47const agentsOf = (text: string): Agent[] =>
48  records(text, 'agents').flatMap(item => {
49    const pane = string(item.pane_id)
50    const tabId = string(item.tab_id)
51    return pane && tabId ? [{ pane, tabId, name: string(item.agent) ?? '', status: agentStateOf(string(item.agent_status) ?? string(item.status) ?? string(item.state)) }] : []
52  })
53
54const stateOf = (text: string): (Status & { isLead: boolean; updatedAt: number }) | undefined => {
55  try {
56    const value = JSON.parse(text) as Json
57    return value.isLead === true && typeof value.updatedAt === 'number' ? value as Status & { isLead: boolean; updatedAt: number } : undefined
58  } catch {
59    return undefined
60  }
61}
62
63const taskOf = (text: string): string | undefined => {
64  try {
65    const task = (JSON.parse(text) as Json).task
66    return typeof task === 'string' && task.trim() ? task.trim() : undefined
67  } catch {
68    return undefined
69  }
70}
71
72const herdr = async ($: EngineInterface, args: string[]) => {
73  const response = await $.process.run(['herdr', ...args], { timeoutMs: 10_000 }).catch(() => undefined)
74  return response?.exitCode === 0 && !response.isStdoutTruncated ? response.stdout : undefined
75}
76
77const key = (workspace: string, tabId: string) => `${workspace}\u0000${tabId}`
78
79async function mainsOf($: EngineInterface, home: string, panes: Pane[], labels: Map<string, string>, now: number): Promise<Main[]> {
80  const live = new Map(panes.map(pane => [pane.pane, pane]))
81  const root = `${home}/.local/state/imadoko`
82  const workspaces = (await $.fs.list(root).catch(() => [])).filter(entry => entry.kind === 'dir')
83  const candidates = await Promise.all(
84    workspaces.map(async workspace =>
85      Promise.all(
86        (await $.fs.list(`${root}/${workspace.name}`).catch(() => []))
87          .filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
88          .map(async entry => ({ pane: entry.name.slice(0, -'.json'.length), state: stateOf(await $.fs.read(`${root}/${workspace.name}/${entry.name}`).catch(() => '')) })),
89      ),
90    ),
91  )
92  const newest = new Map<string, Status & { isLead: boolean; updatedAt: number }>()
93  for (const candidate of candidates.flat()) {
94    const previous = newest.get(candidate.pane)
95    if (candidate.state && live.has(candidate.pane) && now - candidate.state.updatedAt >= 0 && now - candidate.state.updatedAt <= FRESH_MS && (previous === undefined || previous.updatedAt < candidate.state.updatedAt)) {
96      newest.set(candidate.pane, candidate.state)
97    }
98  }
99  return [...newest].flatMap(([paneId, status]) => {
100    const pane = live.get(paneId)!
101    return [{ workspace: pane.workspace, pane: pane.pane, tabId: pane.tabId, mark: labels.get(key(pane.workspace, pane.tabId)) ?? '', purpose: typeof status.purpose === 'string' ? status.purpose : '', status }]
102  })
103}
104
105async function refresh($: EngineInterface) {
106  const now = await $.clock.now()
107  const [home, paneText, agentText] = await Promise.all([$.env.get('HOME'), herdr($, ['pane', 'list']), herdr($, ['agent', 'list'])])
108  if (!home || !paneText) return
109  const panes = panesOf(paneText)
110  const workspaces = [...new Set(panes.map(pane => pane.workspace))]
111  const tabTexts = await Promise.all(workspaces.map(workspace => herdr($, ['tab', 'list', '--workspace', workspace])))
112  const labels = new Map(tabTexts.flatMap((text, index) => text === undefined ? [] : tabsOf(text).map(tab => [key(workspaces[index]!, tab.tabId), tab.label] as const)))
113  const blocked = await Promise.all(
114    agentsOf(agentText ?? '')
115      .filter(agent => agent.status === 'blocked')
116      .flatMap(agent => {
117        const pane = panes.find(item => item.pane === agent.pane && item.tabId === agent.tabId)
118        return pane ? [{ agent, pane }] : []
119      })
120      .map(async ({ agent, pane }) => ({
121        workspace: pane.workspace,
122        tabId: agent.tabId,
123        name: agent.name,
124        label: labels.get(key(pane.workspace, agent.tabId)) ?? '',
125        task: taskOf(await $.fs.read(`${home}/.local/state/herdr-team/${pane.workspace}/${agent.tabId}.json`).catch(() => '')),
126      })),
127  )
128  const mains = await mainsOf($, home, panes, labels, now)
129  await update($, board, () => cards(mains, blocked, now))
130}
131
132let poll: { cancel: () => void } | undefined
133
134const startPolling = ($: EngineInterface) => {
135  poll?.cancel()
136  let busy = false
137  poll = $.clock.every(POLL_MS, () => {
138    if (busy) return
139    busy = true
140    void refresh($).finally(() => {
141      busy = false
142    })
143  })
144}
145
146const hostOf = (url: string | undefined) => url?.match(/^https?:\/\/([^/?#]+)/)?.[1]
147
148const elapsed = (ms: number | undefined) => {
149  if (ms === undefined) return '—'
150  if (ms < 60_000) return `${Math.floor(ms / 1000)}秒`
151  if (ms < 60 * 60_000) return `${Math.floor(ms / 60_000)}分`
152  return `${Math.floor(ms / (60 * 60_000))}時間`
153}
154
155export const register: Register = on => {
156  on('session.start', async ($, event, next) => {
157    await $.command.register({ name: 'overview', description: 'herdr の全 main をカンバンで表示する' })
158    if ((await $.env.get('HERDR_ENV')) === '1' && await read($, isOpen)) {
159      void refresh($).catch(() => undefined)
160      startPolling($)
161    }
162    return next(event)
163  })
164
165  on('command.run', { command: 'overview' }, async $ => {
166    if ((await $.env.get('HERDR_ENV')) === '1') {
167      await refresh($)
168      await update($, isOpen, () => true)
169      startPolling($)
170    }
171    await $.ui.open({ id: PANE, title: 'overview', columns: 200, closeOnEscape: true })
172    return { text: 'Overview pane opened.' }
173  })
174
175  on('ui.close', { id: PANE }, async ($, event, next) => {
176    await update($, isOpen, () => false)
177    poll?.cancel()
178    poll = undefined
179    return next(event)
180  })
181
182  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, event) => {
183    const { Box, Button, Text } = $.ui.resolve(event)
184    if ((await $.env.get('HERDR_ENV')) !== '1') return <Text>herdr の中で開いてください</Text>
185    const current = await read($, board)
186    const columns = [
187      ['reply', '要返信', 'warning', '#ffc8d8'],
188      ['working', '作業中', undefined, '#fff3a0'],
189      ['review', 'レビュー待ち', undefined, '#c8e4ff'],
190      ['done', '完了', undefined, '#cdeccd'],
191    ] as const
192    return (
193      <Box flexDirection="row" gap={2}>
194        {columns.map(([column, title, color, paper]) => (
195          <Box key={column} flexDirection="column" flexGrow={1} gap={1}>
196            <Text bold color={color}>{`${title} (${current[column].length})`}</Text>
197            {current[column].map((card, index) => (
198              <Box key={`box:${column}:${index}`} flexDirection="column" backgroundColor={paper} paddingX={1} paddingY={1}>
199                <Box flexDirection="row" justifyContent="space-between">
200                  <Text bold color="black">{card.title}</Text>
201                  <Box flexDirection="row" gap={1}>
202                    {card.url ? <Button key={`url:${column}:${index}`} plain label="🔗" onPress={() => $.process.run(['open', card.url!], { timeoutMs: 10_000 }).catch(() => undefined)} /> : null}
203                    <Button key={`card:${column}:${index}`} plain label="↗" onPress={() => herdr($, ['tab', 'focus', card.tabId])} />
204                  </Box>
205                </Box>
206                <Text color="#555555">{[card.workspace, card.mark, elapsed(card.elapsedMs), hostOf(card.url)].filter(Boolean).join(' · ')}</Text>
207              </Box>
208            ))}
209          </Box>
210        ))}
211      </Box>
212    )
213  })
214}
215
hooks/board.ts 78 lines
1export type Task = { title?: unknown; state?: unknown; waitsFor?: unknown; url?: unknown }
2
3export type Status = {
4  purpose?: unknown
5  tasks?: Task[]
6  pending?: unknown[]
7  isWorking?: unknown
8  idleSince?: unknown
9  updatedAt?: unknown
10}
11
12export type Main = {
13  workspace: string
14  pane: string
15  tabId?: string
16  mark?: string
17  purpose?: string
18  status: Status
19}
20
21export type Blocked = { workspace: string; tabId: string; name: string; label: string; task?: string }
22
23export type Column = 'reply' | 'working' | 'review' | 'done'
24
25export type Card = { column: Column; title: string; workspace: string; mark: string; tabId: string; elapsedMs?: number; url?: string }
26
27export type Board = Record<Column, Card[]>
28
29const card = (column: Column, main: Main, title: string): Card | undefined =>
30  main.tabId === undefined ? undefined : { column, title, workspace: main.workspace, mark: main.mark ?? '', tabId: main.tabId }
31
32export const cards = (mains: Main[], blocked: Blocked[], now: number): Board => {
33  const reply = blocked.map(agent => ({ column: 'reply' as const, title: agent.task ?? agent.label, workspace: agent.workspace, mark: agent.label, tabId: agent.tabId }))
34  const pending: Card[] = []
35  const working: Card[] = []
36  const review: Card[] = []
37  const done: Card[] = []
38
39  for (const main of mains) {
40    const { status } = main
41    if (status.isWorking === false && Array.isArray(status.pending)) {
42      const elapsedMs = typeof status.idleSince === 'number' ? Math.max(0, now - status.idleSince) : undefined
43      for (const title of status.pending) {
44        if (typeof title !== 'string') continue
45        const next = card('reply', main, title)
46        if (next !== undefined) pending.push({ ...next, ...(elapsedMs !== undefined && { elapsedMs }) })
47      }
48    }
49
50    const tasks = status.tasks ?? []
51    for (const task of tasks) {
52      if (typeof task.title !== 'string') continue
53      if (task.state === 'doing') {
54        const next = card('working', main, task.title)
55        if (next !== undefined) working.push(next)
56      }
57      if (task.state === 'waiting' && task.waitsFor === 'others') {
58        const next = card('review', main, task.title)
59        if (next !== undefined) review.push(typeof task.url === 'string' && /^https?:\/\/\S+$/.test(task.url) ? { ...next, url: task.url } : next)
60      }
61    }
62    if (status.isWorking !== true && tasks.length > 0 && tasks.every(task => task.state === 'done') && Array.isArray(status.pending) && status.pending.length === 0) {
63      const next = card('done', main, main.purpose?.trim() || (main.mark ?? ''))
64      if (next !== undefined) done.push(next)
65    }
66  }
67
68  pending.sort((a, b) => (b.elapsedMs ?? -1) - (a.elapsedMs ?? -1))
69  return { reply: [...reply, ...pending], working, review, done }
70}
71
72const ALIASES: Readonly<Record<string, string>> = { busy: 'working', running: 'working', waiting: 'blocked', finished: 'done' }
73
74export const agentStateOf = (raw: string | undefined): string => {
75  const state = (raw ?? '').toLowerCase()
76  return Object.hasOwn(ALIASES, state) ? ALIASES[state]! : state
77}
78
types/index.d.ts 10 lines
1export type OverviewColumn = 'reply' | 'working' | 'review' | 'done'
2export type OverviewCard = { column: OverviewColumn; title: string; workspace: string; mark: string; tabId: string; elapsedMs?: number; url?: string }
3export type OverviewBoard = Record<OverviewColumn, OverviewCard[]>
4
5declare module 'claude-code' {
6  interface PluginState {
7    'overview': { board: OverviewBoard; isOpen: boolean }
8  }
9}
10