SLOPSHOPPER

branch-watch

ステータス行に worktree / branch を出し、どのセッションがどの worktree のどのブランチで作業しているかをペインに並べる。別のセッションが切り替えたブランチでの commit を止める

newpaneguardcommandtoaststatus
v0.1.0MITupdated 2026-10-09yut0takagi/mods/branch-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · branch-watch
│ ┃ セッションと worktree ✕ › fix the failing auth test and add an audit log call │ ┃ 動いているセッション 1 [ 更新 ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎇ app / feat/auth-refresh ⎿ Read 6 lines │ ┃ /work/app ⏺ Update(src/auth.ts) │ ┃ ○ 待機中 このセッション fix the failing auth ⎿ 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 │ │ › /branch-watch │ ⎿ branch-watch: /work/app: feat/auth-refresh(基準どおり) │ ⎿ branch-watch: 動いている 1 セッションをペインに出しました。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ branch-watch: ⎇ app / feat/auth-refresh

Draws

Pane · セッションと worktree
動いているセッション 1 [ 更新 ] ⎇ app / feat/auth-refresh /work/app ○ 待機中 このセッション fix the failing auth test
README

mods

English | 日本語

A marketplace of mods for Claude Code. A mod is a plugin that adds features to Claude Code through function hooks.

ModWhat it does
branch-watchStops commits on a branch another session switched to, and lists each session's worktree and branch
push-gateHolds git push and similar commands until a person approves
auto-worktreeCreates a worktree on the first prompt and starts the work there
session-diffShows the files this session edited and their diffs
pr-paneShows open pull requests and their CI results
side-question/btw asks a side question without stopping the work
done-chimePlays a sound when a long turn ends and when Claude waits for permission
ja-checkTells you when a reply starts in English
usage-meterShows each turn's token counts and prompt cache rate

Install

claude plugin marketplace add yut0takagi/mods
claude plugin install branch-watch@yut0takagi-mods   # one line per mod you want

Developed with Claude Code 2.1.289. Mods are an early access feature, so a Claude Code update may break them. The VS Code and Cursor extension draws no mod UI, so some mods are used differently there.

Docs

To change a mod, read CONTRIBUTING. Changes are listed in the CHANGELOG. Report security problems as described in SECURITY.

License

MIT

Source 4 files
hooks/register.tsx 409 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
3
4import type { Baselines, Peer } from '../types'
5import { denyText, dirOf, driftNote, isCommit, isSwitch, keyOf, parseRepo, statusText } from './git'
6import type { Repo } from './git'
7import { countIn, driftOf, groupsOf, isLive, labelOf, listText, parsePeer, PRUNE_MS, stateOf, titleOf, whoOf } from './sessions'
8
9// 基準は $.state に置く。モジュールの変数だと読み込み直しで消え、そのあいだの切り替えを見逃す
10const baselines = atom({ plugin: 'branch-watch', key: 'baselines' } as const, {} as Baselines)
11const warned = atom({ plugin: 'branch-watch', key: 'warned' } as const, '')
12const noted = atom({ plugin: 'branch-watch', key: 'noted' } as const, '')
13const here = atom({ plugin: 'branch-watch', key: 'here' } as const, '')
14const label = atom({ plugin: 'branch-watch', key: 'label' } as const, { id: '', text: '' })
15const working = atom({ plugin: 'branch-watch', key: 'isWorking' } as const, false)
16const peers = atom({ plugin: 'branch-watch', key: 'peers' } as const, [] as Peer[])
17
18const PANE = 'branch-watch'
19
20// 待機中に別のセッションが切り替えたのも拾うため、ツール呼び出しのほかに一定間隔でも見る。
21// 同じ間隔で自分の様子を共有のディレクトリに書き、ほかのセッションの様子を読む
22const POLL_MS = 15_000
23
24async function repoAt($: EngineInterface, dir: string): Promise<Repo | undefined> {
25  const git = (args: string[]) => $.process.run(['git', ...args], { cwd: dir, timeoutMs: 5_000 }).catch(() => undefined)
26  // `rev-parse --abbrev-ref HEAD` はコミットのないリポジトリで失敗するので、ルートとブランチは別々に聞く
27  const [top, ref] = await Promise.all([git(['rev-parse', '--show-toplevel']), git(['symbolic-ref', '--short', '-q', 'HEAD'])])
28
29  return top?.exitCode === 0 && ref !== undefined ? parseRepo(top.stdout, ref) : undefined
30}
31
32/** その worktree の基準ブランチ。初めて見る worktree なら今のブランチを基準にする。 */
33async function baselineOf($: EngineInterface, repo: Repo): Promise<string> {
34  const known = (await read($, baselines))[repo.root]
35
36  if (known !== undefined) {
37    return known
38  }
39
40  await update($, baselines, all => ({ ...all, [repo.root]: repo.branch }))
41
42  return repo.branch
43}
44
45const accept = ($: EngineInterface, repo: Repo) =>
46  update($, baselines, all => ({ ...all, [repo.root]: repo.branch }))
47
48/** このセッションが作業している場所。まだどこでも作業していなければセッションの cwd。 */
49const whereOf = async ($: EngineInterface) => (await read($, here)) || (await $.session.cwd())
50
51/** 作業場所を `dir` の worktree に移す。git の外(~/.claude のメモなど)での作業では動かさない。 */
52async function moveTo($: EngineInterface, dir: string) {
53  const repo = await repoAt($, dir)
54
55  if (repo !== undefined) {
56    await update($, here, () => repo.root)
57  }
58}
59
60/** セッションの様子を置くディレクトリ。どのセッションからも同じ場所になる。 */
61async function registryOf($: EngineInterface): Promise<string | undefined> {
62  const config = await $.env.get('CLAUDE_CONFIG_DIR')
63  const home = await $.env.get('HOME')
64  const base = config || (home === undefined ? undefined : `${home}/.claude`)
65
66  return base === undefined ? undefined : `${base}/branch-watch/sessions`
67}
68
69/** 自分の様子を書き、動いているほかのセッションの様子を読む。古いファイルは消す。 */
70async function exchange($: EngineInterface, registry: string, self: Peer): Promise<Peer[]> {
71  await $.fs.write(`${registry}/${self.id}.json`, JSON.stringify(self)).catch(() => undefined)
72
73  const before = await read($, peers)
74  const entries = await $.fs.list(registry).catch(() => [])
75  const files = entries.filter(entry => entry.kind === 'file' && entry.name.endsWith('.json') && entry.name !== `${self.id}.json`)
76  const stale = files.filter(entry => self.updatedAt - entry.mtimeMs > PRUNE_MS).map(entry => `${registry}/${entry.name}`)
77
78  if (stale.length > 0) {
79    void $.process.run(['rm', '-f', ...stale], { timeoutMs: 5_000 }).catch(() => undefined)
80  }
81
82  const found = await Promise.all(
83    files
84      .filter(entry => self.updatedAt - entry.mtimeMs <= PRUNE_MS)
85      .map(async entry => {
86        const text = await $.fs.read(`${registry}/${entry.name}`).catch(() => undefined)
87        const id = entry.name.slice(0, -'.json'.length)
88
89        // 相手が書いている途中で読むと壊れていることがある。そのときは前に読んだものを使う
90        return (text === undefined ? undefined : parsePeer(text)) ?? before.find(peer => peer.id === id)
91      }),
92  )
93
94  return found.filter((peer): peer is Peer => peer !== undefined && isLive(peer, self.updatedAt))
95}
96
97/** ステータス行とセッションの一覧を今に合わせ、基準からずれたら一度だけ知らせる。 */
98async function refresh($: EngineInterface) {
99  const id = await $.session.id()
100  const dir = await whereOf($)
101  const repo = await repoAt($, dir)
102  const baseline = repo === undefined ? undefined : await baselineOf($, repo)
103  const named = await read($, label)
104  const self: Peer = {
105    id,
106    label: named.id === id ? named.text : '',
107    dir,
108    ...(repo !== undefined && { root: repo.root, branch: repo.branch, baseline }),
109    isWorking: await read($, working),
110    updatedAt: await $.clock.now(),
111  }
112  const registry = await registryOf($)
113  const others = registry === undefined ? [] : await exchange($, registry, self)
114  await update($, peers, () => [self, ...others])
115
116  if (repo === undefined || baseline === undefined) {
117    $.ui.status(undefined)
118
119    return
120  }
121
122  $.ui.status(statusText(repo, baseline, countIn(others, repo.root)))
123
124  const key = keyOf(repo)
125
126  if (repo.branch !== baseline && (await read($, warned)) !== key) {
127    await update($, warned, () => key)
128    $.ui.toast(`branch-watch: ${baseline} → ${repo.branch} に切り替わりました`, { timeoutMs: 8_000 })
129  }
130}
131
132/**
133 * 画面があるか。VS Code の拡張機能のセッションには無く、ペインもステータス行もトーストも描かれない
134 * (ui.open は isPlaced: true を返すので、それでは見分けられない)。分からないときはあるとみなし、今までどおりにする。
135 */
136async function hasScreen($: EngineInterface): Promise<boolean> {
137  const surfaces = await $.session.surfaces().catch(() => undefined)
138
139  return surfaces === undefined || surfaces.length > 0
140}
141
142/** このずれはもう伝えたことにする。画面がないときの添え書きで同じことを繰り返さない。 */
143const markNoted = ($: EngineInterface, repo: Repo) => update($, noted, () => keyOf(repo))
144
145/**
146 * 画面がないセッションで、基準からのずれをまだモデルに伝えていなければ、その添え書き。
147 * トーストと同じく、同じずれは一度だけ伝える。
148 */
149async function noteOf($: EngineInterface): Promise<string | undefined> {
150  if (await hasScreen($)) {
151    return undefined
152  }
153
154  const repo = await repoAt($, await whereOf($))
155
156  if (repo === undefined) {
157    return undefined
158  }
159
160  const baseline = await baselineOf($, repo)
161
162  if (repo.branch === baseline || (await read($, noted)) === keyOf(repo)) {
163    return undefined
164  }
165
166  await markNoted($, repo)
167
168  return driftNote(repo, baseline)
169}
170
171/**
172 * ターンの途中に起きたずれを、ツールの結果に添えてモデルに伝える(画面がないときだけ)。
173 * サブエージェントに伝えても人には届かないので、メインのループのぶんにだけ添える。
174 */
175async function withNote<T extends ToolCallResult>($: EngineInterface, agentId: string | undefined, ran: T): Promise<T> {
176  if (agentId !== undefined || ran.deny !== undefined) {
177    return ran
178  }
179
180  const note = await noteOf($).catch(() => undefined)
181
182  return note === undefined ? ran : { ...ran, context: [...(ran.context ?? []), note] }
183}
184
185/** 名札がまだなければ付ける。再開したセッションは、それまでの最初のプロンプトから取る。 */
186async function nameFrom($: EngineInterface, prompt?: string) {
187  const id = await $.session.id()
188  const named = await read($, label)
189
190  if (named.id === id && named.text !== '') {
191    return
192  }
193
194  const first =
195    prompt ??
196    (await $.session.messages().catch(() => []))
197      .filter(message => message.role === 'user')
198      .map(message => labelOf(message.text))
199      .find(text => text !== '')
200  const text = labelOf(first ?? '')
201
202  if (text !== '') {
203    await update($, label, () => ({ id, text }))
204  }
205}
206
207const parentOf = (file: string) => file.slice(0, file.lastIndexOf('/')) || '/'
208
209export const register: Register = on => {
210  on('session.start', async ($, e, next) => {
211    // 画面がない(VS Code の拡張機能など)と、一覧は返答に出す。説明もそれに合わせる
212    const where = (await hasScreen($)) ? 'ペイン' : '返答'
213    await $.command.register({
214      name: 'branch-watch',
215      description: `セッションごとの worktree / branch を${where}に出す。accept で今のブランチを基準にする`,
216      argumentHint: '[accept]',
217    })
218    await nameFrom($)
219    await refresh($)
220    $.clock.every(POLL_MS, () => void refresh($))
221
222    return next(e)
223  })
224
225  on('session.end', async ($, e, next) => {
226    const registry = await registryOf($)
227
228    if (registry !== undefined) {
229      await $.process.run(['rm', '-f', `${registry}/${e.sessionId}.json`], { timeoutMs: 5_000 }).catch(() => undefined)
230    }
231
232    return next(e)
233  })
234
235  on('turn.start', async ($, e, next) => {
236    await update($, working, () => true)
237    await nameFrom($, e.text)
238    await refresh($)
239
240    return next(e)
241  })
242
243  // 画面がないと待機中のずれをトーストで知らせられないので、次のプロンプトに添えてモデルに伝え、返答で知らせてもらう
244  on('prompt.submit', async ($, e, next) => {
245    // 様子が読めないときも、プロンプトは止めずにそのまま送る
246    const note = await noteOf($).catch(() => undefined)
247
248    return next(note === undefined ? e : { ...e, context: [...(e.context ?? []), note] })
249  })
250
251  on('turn.complete', async ($, e, next) => {
252    const done = await next(e)
253
254    // サブエージェントのターンが終わっても、セッションのターンは続いている
255    if (e.agentId === undefined) {
256      await update($, working, () => false)
257      await refresh($)
258    }
259
260    return done
261  })
262
263  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
264    const dir = dirOf(e.command, await $.session.cwd(), await $.env.get('HOME'))
265
266    if (isCommit(e.command)) {
267      const repo = await repoAt($, dir)
268
269      if (repo !== undefined) {
270        const baseline = await baselineOf($, repo)
271
272        if (repo.branch !== baseline) {
273          // 拒否の文がずれを伝えるので、画面がないときの添え書きでは繰り返さない
274          if (e.agentId === undefined) {
275            await markNoted($, repo)
276          }
277
278          return { deny: denyText(repo, baseline) }
279        }
280      }
281    }
282
283    const ran = await next(e)
284
285    // このセッションが自分で切り替えたなら、それを新しい基準にする
286    if (isSwitch(e.command)) {
287      const repo = await repoAt($, dir)
288
289      if (repo !== undefined) {
290        await accept($, repo)
291      }
292    }
293
294    await moveTo($, dir)
295    await refresh($)
296
297    return withNote($, e.agentId, ran)
298  })
299
300  on('tool.call', async ($, e, next) => {
301    const file =
302      e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : undefined
303
304    if (file === undefined) {
305      return next(e)
306    }
307
308    const ran = await next(e)
309    await moveTo($, parentOf(file))
310    await refresh($)
311
312    return withNote($, e.agentId, ran)
313  })
314
315  on('command.run', { command: 'branch-watch' }, async ($, e) => {
316    const dir = await whereOf($)
317    const repo = await repoAt($, dir)
318
319    if (e.args.trim() === 'accept') {
320      if (repo === undefined) {
321        return { text: `${dir} は git のリポジトリではありません。` }
322      }
323
324      // 基準の付け替えは、人が入力したときだけ受け付ける(モデルやほかのプラグインからは通さない)
325      if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') {
326        return { text: 'branch-watch: 基準の付け替えは、人が入力したときだけ受け付けます。' }
327      }
328
329      await accept($, repo)
330      await refresh($)
331
332      return { text: `${repo.root} の基準を ${repo.branch} にしました。` }
333    }
334
335    await refresh($)
336
337    const baseline = repo === undefined ? undefined : await baselineOf($, repo)
338    const head =
339      repo === undefined || baseline === undefined
340        ? `${dir} は git のリポジトリではありません。`
341        : repo.branch === baseline
342          ? `${repo.root}: ${repo.branch}(基準どおり)`
343          : `${repo.root}: いまは ${repo.branch}、このセッションの基準は ${baseline}`
344    const list = await read($, peers)
345    // 画面がないと ui.open は isPlaced: true を返すのに描かれない。そのときは開かずに一覧を返答に出す
346    const opened = (await hasScreen($))
347      ? await $.ui.open({ id: PANE, title: 'セッションと worktree' }).catch(() => ({ isPlaced: false }) as const)
348      : ({ isPlaced: false } as const)
349
350    // 返答で見せたずれは、次のプロンプトに添えて繰り返さない
351    if (repo !== undefined && baseline !== undefined && repo.branch !== baseline) {
352      await markNoted($, repo)
353    }
354
355    return {
356      text: opened.isPlaced
357        ? `${head}\n動いている ${list.length} セッションをペインに出しました。`
358        : `${head}\n\n${listText(groupsOf(list, list[0]?.id ?? ''), list[0]?.id ?? '')}`,
359    }
360  })
361
362  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
363    const { Box, Button, Text } = $.ui.resolve(e)
364    const list = await read($, peers)
365    const selfId = list[0]?.id ?? ''
366
367    return (
368      <Box flexDirection="column">
369        <Box gap={1}>
370          <Text dimColor>{`動いているセッション ${list.length}`}</Text>
371          <Button key="refresh" label="更新" onPress={() => refresh($)} />
372        </Box>
373        {groupsOf(list, selfId).map(group => (
374          <Box key={`wt-${group.root ?? ''}`} flexDirection="column" marginTop={1}>
375            <Box gap={1}>
376              <Text bold wrap="truncate-end">
377                {titleOf(group)}
378              </Text>
379              {group.root !== undefined && group.peers.length > 1 && (
380                <Text color="yellow">{`⚠ ${group.peers.length} セッション`}</Text>
381              )}
382            </Box>
383            {group.root !== undefined && (
384              <Text dimColor wrap="truncate-start">
385                {group.root}
386              </Text>
387            )}
388            {group.peers.map(peer => (
389              <Box key={`peer-${peer.id}`} gap={1}>
390                <Text color={peer.isWorking ? 'green' : undefined} dimColor={!peer.isWorking}>
391                  {stateOf(peer)}
392                </Text>
393                <Text bold={peer.id === selfId}>{whoOf(peer, selfId)}</Text>
394                <Text wrap="truncate-end">{peer.label}</Text>
395                {group.root === undefined && (
396                  <Text dimColor wrap="truncate-start">
397                    {peer.dir}
398                  </Text>
399                )}
400                {driftOf(peer) !== undefined && <Text color="yellow">{driftOf(peer)}</Text>}
401              </Box>
402            ))}
403          </Box>
404        ))}
405      </Box>
406    )
407  })
408}
409
hooks/git.ts 85 lines
1/** Bash のコマンドの読み取りと、ステータス行・拒否の文言。$ に触れない部分。 */
2
3export type Repo = {
4  /** worktree のルート。基準ブランチはこれごとに持つ。 */
5  root: string
6  /** 今のブランチ。detached HEAD なら `HEAD`。 */
7  branch: string
8}
9
10// `git -C <dir> commit` や、前に別のコマンドを付けた `command git commit` も拾う
11const GIT = String.raw`\bgit\s+(?:-[Cc]\s+\S+\s+)*`
12const COMMIT = new RegExp(`${GIT}commit\\b`)
13const SWITCH = new RegExp(`${GIT}(?:checkout|switch)\\b|\\bgh\\s+pr\\s+checkout\\b`)
14const GIT_DIR = /\bgit\s+-C\s+("[^"]*"|'[^']*'|\S+)/
15const CD_DIR = /^\s*cd\s+("[^"]*"|'[^']*'|\S+)\s*(?:&&|;)/
16
17export const isCommit = (command: string) => COMMIT.test(command)
18
19/** このセッションが自分でブランチを切り替えるコマンドか。 */
20export const isSwitch = (command: string) => SWITCH.test(command)
21
22const unquote = (word: string) => word.replace(/^(["'])(.*)\1$/, '$2')
23
24/** コマンドが git を走らせるディレクトリ。`git -C <dir>` と先頭の `cd <dir> &&` を読み、なければ cwd。 */
25export function dirOf(command: string, cwd: string, home: string | undefined): string {
26  const word = (GIT_DIR.exec(command) ?? CD_DIR.exec(command))?.[1]
27
28  if (word === undefined) {
29    return cwd
30  }
31
32  const dir = unquote(word)
33
34  if (dir === '~' || dir.startsWith('~/')) {
35    return home === undefined ? cwd : home + dir.slice(1)
36  }
37
38  return dir.startsWith('/') ? dir : `${cwd}/${dir}`
39}
40
41/**
42 * `git rev-parse --show-toplevel` の出力と、`git symbolic-ref --short -q HEAD` の結果。
43 * symbolic-ref はコミットがなくてもブランチ名を返し、detached HEAD なら出力なしの 1 で終わる。
44 * それ以外の失敗(タイムアウトなど)を detached と取り違えると commit を止めてしまうので、分からないとする。
45 */
46export function parseRepo(toplevel: string, ref: { exitCode: number; stdout: string }): Repo | undefined {
47  const root = toplevel.trim()
48  const branch = ref.exitCode === 0 ? ref.stdout.trim() : ref.exitCode === 1 ? 'HEAD' : ''
49
50  return root && branch ? { root, branch } : undefined
51}
52
53export const nameOf = (root: string) => root.split('/').pop() ?? root
54
55/** 同じずれを何度も知らせないための鍵。`<root>@<branch>`。 */
56export const keyOf = (repo: Repo) => `${repo.root}@${repo.branch}`
57
58/** `others` は、同じ worktree で動いているほかのセッションの数。 */
59export function statusText(repo: Repo, baseline: string, others = 0): string {
60  const where = `⎇ ${nameOf(repo.root)} / ${repo.branch}`
61  const text = repo.branch === baseline ? where : `⚠ ${where}(このセッションは ${baseline})`
62
63  return others > 0 ? `${text} 👥 +${others}` : text
64}
65
66export function denyText(repo: Repo, baseline: string): string {
67  return [
68    `branch-watch: ${repo.root} のブランチが ${baseline} から ${repo.branch} に変わっています。`,
69    'このセッションが切り替えたものではないので、別のセッションが切り替えた可能性があります。commit は止めました。',
70    `ユーザーに伝えて判断を仰いでください(${baseline} に戻すなら git checkout ${baseline}、今のブランチで続けるならユーザーが /branch-watch accept を実行する)。`,
71  ].join('\n')
72}
73
74/**
75 * 画面がないセッション(VS Code の拡張機能など)で、トーストの代わりにモデルへ添える文。
76 * ユーザーにはステータス行もトーストも見えないので、返答で知らせてもらう。
77 */
78export function driftNote(repo: Repo, baseline: string): string {
79  return [
80    `branch-watch: ${repo.root} のブランチが、このセッションの基準 ${baseline} から ${repo.branch} に変わっています。`,
81    'このセッションが切り替えたものではないので、別のセッションが切り替えた可能性があります。この画面にはステータス行もトーストも出ないため、ユーザーはまだ気づいていません。',
82    `返答の冒頭でユーザーに一言伝えてください。戻すかどうかはユーザーが決めます(${baseline} に戻すなら git checkout ${baseline}、今のブランチで続けるならユーザーが /branch-watch accept を実行する)。`,
83  ].join('\n')
84}
85
hooks/sessions.ts 126 lines
1/** 動いているセッションの一覧。共有のファイルの読み取りと、並べ方と、文言。$ に触れない部分。 */
2
3import type { Peer } from '../types'
4import { nameOf } from './git'
5
6/** これより長く書かれていないセッションは止まったとみなす。各セッションは 15 秒ごとに書く。 */
7export const LIVE_MS = 60_000
8
9/** これより古いファイルは消す。落ちたセッションのぶんが溜まらないように。 */
10export const PRUNE_MS = 10 * 60_000
11
12const LABEL_LENGTH = 32
13
14/** プロンプトから名札を作る。IDE などが足したタグの塊は除く。 */
15export function labelOf(text: string): string {
16  const plain = text
17    .replace(/<([\w-]+)[^>]*>[\s\S]*?<\/\1>/g, ' ')
18    .replace(/\s+/g, ' ')
19    .trim()
20  const chars = Array.from(plain)
21
22  return chars.length > LABEL_LENGTH ? `${chars.slice(0, LABEL_LENGTH).join('')}…` : plain
23}
24
25const isText = (value: unknown): value is string => typeof value === 'string'
26
27/** 共有のディレクトリから読んだ 1 ファイル。書いている途中で読んだなどで形が違えば undefined。 */
28export function parsePeer(text: string): Peer | undefined {
29  let value: Record<string, unknown>
30
31  try {
32    value = JSON.parse(text) as Record<string, unknown>
33  } catch {
34    return undefined
35  }
36
37  if (!isText(value.id) || !isText(value.dir) || typeof value.updatedAt !== 'number') {
38    return undefined
39  }
40
41  return {
42    id: value.id,
43    label: isText(value.label) ? value.label : '',
44    dir: value.dir,
45    ...(isText(value.root) && { root: value.root }),
46    ...(isText(value.branch) && { branch: value.branch }),
47    ...(isText(value.baseline) && { baseline: value.baseline }),
48    isWorking: value.isWorking === true,
49    updatedAt: value.updatedAt,
50  }
51}
52
53export const isLive = (peer: Peer, now: number) => now - peer.updatedAt < LIVE_MS
54
55/** 同じ worktree で動いているほかのセッションの数。 */
56export const countIn = (others: readonly Peer[], root: string) => others.filter(peer => peer.root === root).length
57
58export type Group = {
59  /** worktree のルート。git の外のセッションをまとめた組では無い。 */
60  root?: string
61  /** いちばん新しく書かれたセッションから見えたブランチ。 */
62  branch?: string
63  peers: Peer[]
64}
65
66/** worktree ごとにまとめる。自分のいる worktree が先頭、git の外は最後。組の中は自分が先頭。 */
67export function groupsOf(peers: readonly Peer[], selfId: string): Group[] {
68  const byRoot = new Map<string, Peer[]>()
69
70  for (const peer of peers) {
71    const key = peer.root ?? ''
72    byRoot.set(key, [...(byRoot.get(key) ?? []), peer])
73  }
74
75  const rank = (root: string, members: readonly Peer[]) =>
76    members.some(peer => peer.id === selfId) ? 0 : root === '' ? 2 : 1
77  // ペインを描き直すたびに並びが入れ替わらないよう、時刻ではなく id で並べる
78  const order = (a: Peer, b: Peer) => Number(b.id === selfId) - Number(a.id === selfId) || a.id.localeCompare(b.id)
79
80  return [...byRoot]
81    .sort(([a, ma], [b, mb]) => rank(a, ma) - rank(b, mb) || a.localeCompare(b))
82    .map(([root, members]) => {
83      const sorted = [...members].sort(order)
84
85      if (root === '') {
86        return { peers: sorted }
87      }
88
89      const freshest = members.reduce((a, b) => (b.updatedAt > a.updatedAt ? b : a))
90
91      return { root, branch: freshest.branch, peers: sorted }
92    })
93}
94
95export const titleOf = (group: Group) =>
96  group.root === undefined ? 'git の外' : `⎇ ${nameOf(group.root)} / ${group.branch ?? '?'}`
97
98export const whoOf = (peer: Peer, selfId: string) => (peer.id === selfId ? 'このセッション' : peer.id.slice(0, 8))
99
100export const stateOf = (peer: Peer) => (peer.isWorking ? '● 作業中' : '○ 待機中')
101
102/** そのセッションの基準ブランチからずれていれば、その文言。 */
103export const driftOf = (peer: Peer) =>
104  peer.baseline !== undefined && peer.branch !== peer.baseline ? `⚠ 基準は ${peer.baseline}` : undefined
105
106/** ペインを出せないときに、コマンドの返答として出す一覧。 */
107export function listText(groups: readonly Group[], selfId: string): string {
108  return groups
109    .flatMap(group => [
110      group.root === undefined ? titleOf(group) : `${titleOf(group)}  ${group.root}`,
111      ...group.peers.map(peer =>
112        [
113          ' ',
114          stateOf(peer),
115          whoOf(peer, selfId),
116          peer.label,
117          group.root === undefined ? peer.dir : '',
118          driftOf(peer) ?? '',
119        ]
120          .filter(part => part !== '')
121          .join(' '),
122      ),
123    ])
124    .join('\n')
125}
126
types/index.d.ts 44 lines
1/** worktree のルート(`git rev-parse --show-toplevel`)から、そのセッションの基準ブランチへ。 */
2export type Baselines = Record<string, string>
3
4/** 共有のディレクトリに書く、1 セッションぶんの様子。ほかのセッションはこれを読んで一覧にする。 */
5export type Peer = {
6  /** session id。ファイル名にもなる。 */
7  id: string
8  /** 最初のプロンプトの書き出し。人がどのセッションかを見分けるためのもの。まだなければ空。 */
9  label: string
10  /** 作業している場所。git の外ならそのディレクトリ。 */
11  dir: string
12  /** 作業している worktree のルート。git の外なら無い。 */
13  root?: string
14  branch?: string
15  /** その worktree でのこのセッションの基準ブランチ。 */
16  baseline?: string
17  /** ターンの途中か。 */
18  isWorking: boolean
19  /** 最後に書いた時刻(エポックからのミリ秒)。 */
20  updatedAt: number
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'branch-watch': {
26      baselines: Baselines
27      /** 最後にトーストで知らせた `<root>@<branch>`。同じ切り替えを何度も知らせない。 */
28      warned: string
29      /**
30       * 画面がないセッションで、最後にモデルへ伝えた `<root>@<branch>`。
31       * トーストの代わりにプロンプトかツールの結果へ添えるので、同じずれを何度も添えない。
32       */
33      noted: string
34      /** 最後にファイルを編集したか、コマンドを実行した worktree のルート。まだなら空で、セッションの cwd を使う。 */
35      here: string
36      /** そのセッションの名札。`id` が変わったら(/clear)取り直す。 */
37      label: { id: string; text: string }
38      isWorking: boolean
39      /** 自分を含む、動いているセッションの一覧。自分が先頭。ペインが読む。 */
40      peers: Peer[]
41    }
42  }
43}
44