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

English | 日本語
A marketplace of mods for Claude Code. A mod is a plugin that adds features to Claude Code through function hooks.
| Mod | What it does |
|---|---|
| branch-watch | Stops commits on a branch another session switched to, and lists each session's worktree and branch |
| push-gate | Holds git push and similar commands until a person approves |
| auto-worktree | Creates a worktree on the first prompt and starts the work there |
| session-diff | Shows the files this session edited and their diffs |
| pr-pane | Shows open pull requests and their CI results |
| side-question | /btw asks a side question without stopping the work |
| done-chime | Plays a sound when a long turn ends and when Claude waits for permission |
| ja-check | Tells you when a reply starts in English |
| usage-meter | Shows each turn's token counts and prompt cache rate |
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.
To change a mod, read CONTRIBUTING. Changes are listed in the CHANGELOG. Report security problems as described in SECURITY.
hooks/register.tsx 409 lines1import { 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}
409hooks/git.ts 85 lines1/** 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}
85hooks/sessions.ts 126 lines1/** 動いているセッションの一覧。共有のファイルの読み取りと、並べ方と、文言。$ に触れない部分。 */
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}
126types/index.d.ts 44 lines1/** 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