A mod that shows the session at a glance in one pane: what needs attention, ship-session and acceptance progress, the current branch's PR (CI, review…

English | 日本語
A Claude Code mod that shows the current session at a glance in one pane: what needs attention, ship-session and acceptance progress, the current branch's PR, and the servers and containers started in this session. It also blocks Bash calls that mass-kill processes or containers, which would stop other sessions' work too.
claude plugin marketplace add insession-space/claude-ship
claude plugin install session-dashboard@claude-ship
Restart Claude Code and it loads. The mod is the hooks module named under modules in hooks/hooks.json (hooks/dashboard.tsx). A Claude Code without mods support does not load that module, so nothing happens there. The mod reads the PR with gh, so run gh auth login first.
Run /dashboard to open the pane and run it again to close it. In fullscreen it docks beside the transcript; otherwise it sits above the prompt.
Once you open it, the mod reopens it when the next session starts. Claude Code draws a pane opened that way only when the terminal is at least 110 columns wide. If you close it with the pane's close mark or /dashboard, it is not reopened.
Four sections, stacked from top to bottom:
╭ Needs attention 3 ──────────────────────────────────────╮
│ ✖ CI failed test (ubuntu-latest) │
│ ● 1 unresolved review thread │
│ ○ listed in the README │
╰─────────────────────────────────────────────────────────╯
╭ Session ────────────────────────────────────────────────╮
│ ship-session Implementation loop · round 2 goal PR │
│ #33 ████████░░░░ 4/6 Show progress │
╰─────────────────────────────────────────────────────────╯
╭ PR ─────────────────────────────────────────────────────╮
│ #34 draft Show the acceptance progress of the issue… │
│ ✔ 3 ✖ 1 ● 1 Changes requested · unresolved 1 │
│ 2 open PRs awaiting your review │
╰─────────────────────────────────────────────────────────╯
╭ Running 2 ──────────────────────────────────────────────╮
│ :5173 node 12m │
│ docker app-db 12m │
╰─────────────────────────────────────────────────────────╯
lsof -nP -iTCP -sTCP:LISTEN) and docker containers (docker ps) that appeared after the session started, with how long they have been up. Anything that existed before the session started is left outLong titles and items are cut at the end, so rows stay intact at the docked pane width (about 66 columns).
| What | When |
|---|---|
| PR and CI | At session start, when you open the pane, and after a Bash call containing git push or gh pr. Every 60 seconds only while CI checks are pending |
| Running servers and containers | After each Bash call, every 30 seconds, when you open the pane, and at the end of each turn |
| Session values | Whenever ship-session or acceptance-progress change them (read on every draw) |
A PR refresh is one gh pr view, one GraphQL query for unresolved threads and one search for review requests. Nothing is polled while no CI check is pending.
At the end of a turn, if servers or containers started in this session are still running, the mod shows a toast. It does not repeat while the set stays the same, and shows it again when something new appears.
A toast at session end (session.end) was tried and does not show: after /exit the terminal leaves fullscreen and the screen is gone before the toast is drawn. So the reminder is only given at the end of a turn.
The mod blocks these shapes of agent Bash calls and returns, as the reason, examples of stopping by port or name.
| Blocked | Examples in the reason | |
|---|---|---|
pkill -f ... / pkill --full ... | `lsof -ti :5173 \ | xargs kill, kill 12345` |
killall ... | same | |
docker stop $(docker ps -q) / `docker ps -q \ | xargs docker stop (also kill / rm`) | docker stop app-db, docker compose down |
kill <PID>, pkill vite (without -f), docker stop <name> and a docker ps -q narrowed with --filter pass. If the guard's hook fails, the call goes through.
dependencies in plugin.json, so installing session-dashboard alone does not install them. Their values are read with $.state.get; a row is hidden when the plugin is not installed or its value is null. Their type contracts (PluginState) are not redeclared; hooks/dashboard.tsx narrows only the shape it readsBox and Text only, so it draws in the terminal and in the desktop app's Code tabTaken from Claude Code's language setting, then the language of your latest message, then English.
claude plugin validate plugins/session-dashboard
claude plugin test plugins/session-dashboardhooks/dashboard.tsx 388 lines1// いまのセッションの状況(要対応・Session・PR・起動中)を1つのペインに出す mod。
2// あわせて、ほかのセッションのものまで止める一括停止の Bash 呼び出しを止める。
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, RenderChildren } from 'claude-code'
5
6import type { DashboardEntry, DashboardLang, DashboardPr, DashboardPrView } from '../types'
7import type { Snapshot } from './observe'
8import {
9 LABELS,
10 UNRESOLVED_QUERY,
11 bar,
12 containersFromDocker,
13 elapsed,
14 fillBaseline,
15 hasJapanese,
16 isNoPr,
17 langFromSetting,
18 massKillOf,
19 nextLedger,
20 padCells,
21 portsFromLsof,
22 prFromJson,
23 sameLedger,
24 unresolvedFromJson,
25} from './observe'
26
27const PANE = 'session-dashboard'
28/** 一度開いたか($.store のキー)。開いていれば次のセッションの開始時に開き直す */
29const OPENED_KEY = 'opened'
30/** 要対応に並べる受け入れ条件の上限 */
31const MAX_OPEN_CRITERIA = 3
32const LEDGER_EVERY_MS = 30_000
33const CI_EVERY_MS = 60_000
34
35const prView = atom({ plugin: 'session-dashboard', key: 'pr' } as const, { kind: 'unknown' } as DashboardPrView)
36const reviewRequests = atom({ plugin: 'session-dashboard', key: 'reviewRequests' } as const, null as number | null)
37const baseline = atom(
38 { plugin: 'session-dashboard', key: 'baseline' } as const,
39 null as { ports: string[] | null; containers: string[] | null } | null,
40)
41const ledger = atom({ plugin: 'session-dashboard', key: 'ledger' } as const, [] as DashboardEntry[])
42const notified = atom({ plugin: 'session-dashboard', key: 'notified' } as const, [] as string[])
43const lang = atom({ plugin: 'session-dashboard', key: 'lang' } as const, null as DashboardLang | null)
44
45// ---------- ほかのプラグインの値 ----------
46// ship-session と acceptance-progress は必須の依存にしない。両者の契約(PluginState)は
47// ここで再宣言せず、読む形だけをこのファイルの中で狭く書いて確かめる。
48
49type ShipProgressLike = { goal: string | null; phase: number; issue: number | null; reviews: number }
50type IssueLike = { number: number; title: string; done: number; total: number; open: string[] }
51type LooseStateGet = (ref: { plugin: string; key: string }) => Promise<{ value?: unknown }>
52
53const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
54
55const shipProgressOf = (v: unknown): ShipProgressLike | null => {
56 if (!isRecord(v) || typeof v.phase !== 'number') return null
57 return {
58 goal: typeof v.goal === 'string' ? v.goal : null,
59 phase: v.phase,
60 issue: typeof v.issue === 'number' ? v.issue : null,
61 reviews: typeof v.reviews === 'number' ? v.reviews : 0,
62 }
63}
64
65const issueOf = (v: unknown): IssueLike | null => {
66 if (!isRecord(v) || typeof v.number !== 'number' || !Array.isArray(v.groups)) return null
67 const groups = v.groups.filter(isRecord)
68 // acceptance-progress のバンドと同じく、受け入れ条件の見出しがあればそれだけ、無ければ全部を数える
69 const acceptance = groups.filter(g => g.isAcceptance === true)
70 const items = (acceptance.length > 0 ? acceptance : groups)
71 .flatMap(g => (Array.isArray(g.items) ? g.items : []))
72 .filter(isRecord)
73 return {
74 number: v.number,
75 title: typeof v.title === 'string' ? v.title : '',
76 done: items.filter(i => i.isDone === true).length,
77 total: items.length,
78 open: items.filter(i => i.isDone !== true).map(i => String(i.text ?? '')),
79 }
80}
81
82const readOthers = async ($: EngineInterface) => {
83 // `$.state.get` はその場で呼ぶ(validate が束縛を受け付けない)。型は読む形だけに狭める
84 const [progress, issue] = await Promise.all([
85 ($.state.get as unknown as LooseStateGet)({ plugin: 'ship-session', key: 'progress' }).catch(() => ({ value: undefined })),
86 ($.state.get as unknown as LooseStateGet)({ plugin: 'acceptance-progress', key: 'issue' }).catch(() => ({ value: undefined })),
87 ])
88 return { progress: shipProgressOf(progress.value), issue: issueOf(issue.value) }
89}
90
91// ---------- 観測 ----------
92
93const run = async ($: EngineInterface, argv: string[]) => {
94 try {
95 return await $.process.run(argv, { timeoutMs: 15_000 })
96 } catch (err) {
97 return { exitCode: 1, stdout: '', stderr: String(err) }
98 }
99}
100
101// 最後に始めた PR の取得の番号。後から始めた取得が先に終わったとき、古い結果で上書きしない
102let latestPrFetch = 0
103
104/** 現在のブランチの PR と、レビュー依頼の件数を読み直す。 */
105const refreshPr = async ($: EngineInterface): Promise<void> => {
106 const mine = ++latestPrFetch
107 const view = await run($, ['gh', 'pr', 'view', '--json', 'number,title,state,isDraft,url,statusCheckRollup,reviewDecision'])
108 let next: DashboardPrView
109 if (view.exitCode !== 0) {
110 next = isNoPr(view.stderr) ? { kind: 'none' } : { kind: 'error', last: null }
111 } else {
112 const base = prFromJson(view.stdout)
113 if (base === null) next = { kind: 'error', last: null }
114 else {
115 const [owner = '', name = ''] = base.repo.split('/')
116 const threads = await run($, [
117 'gh', 'api', 'graphql', '--paginate', '--slurp',
118 '-f', `query=${UNRESOLVED_QUERY}`,
119 '-f', `owner=${owner}`,
120 '-f', `name=${name}`,
121 '-F', `number=${base.number}`,
122 ])
123 next = { kind: 'pr', pr: { ...base, unresolved: threads.exitCode === 0 ? unresolvedFromJson(threads.stdout) : null } }
124 }
125 }
126 const requests = await run($, ['gh', 'api', '-X', 'GET', 'search/issues', '-f', 'q=is:pr is:open review-requested:@me', '--jq', '.total_count'])
127 if (mine !== latestPrFetch) return
128 await update($, prView, cur => {
129 if (next.kind !== 'error') return next
130 // 読めなかったときは、前に読めた PR を残して印を付ける
131 const last = cur.kind === 'pr' ? cur.pr : cur.kind === 'error' ? cur.last : null
132 return { kind: 'error' as const, last }
133 })
134 const n = Number(requests.stdout.trim())
135 if (requests.exitCode === 0 && Number.isInteger(n)) await update($, reviewRequests, () => n)
136}
137
138const snapshot = async ($: EngineInterface): Promise<Snapshot> => {
139 const [lsof, docker] = await Promise.all([
140 run($, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN']),
141 run($, ['docker', 'ps', '--format', '{{.ID}}\t{{.Names}}\t{{.Image}}']),
142 ])
143 // lsof は待ち受けが1つも無いと 1 で終わる(出力は空)
144 const ports = lsof.exitCode === 0 || (lsof.exitCode === 1 && lsof.stdout === '' && lsof.stderr === '') ? portsFromLsof(lsof.stdout) : null
145 const containers = docker.exitCode === 0 ? containersFromDocker(docker.stdout) : null
146 return { ports, containers }
147}
148
149/** 待ち受けポートとコンテナを撮り直し、基準に無いものを台帳に載せる。 */
150const refreshLedger = async ($: EngineInterface): Promise<DashboardEntry[]> => {
151 const snap = await snapshot($)
152 const now = await $.clock.now()
153 const base = fillBaseline(await read($, baseline), snap)
154 await update($, baseline, () => base)
155 const prev = await read($, ledger)
156 const next = nextLedger(base, snap, prev, now)
157 if (!sameLedger(prev, next)) await update($, ledger, () => next)
158 return next
159}
160
161const isPaneOpen = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE)
162
163const labelsOf = async ($: EngineInterface) => LABELS[(await read($, lang)) ?? 'en']
164
165const describeEntries = (items: DashboardEntry[]) => items.map(e => (e.kind === 'port' ? `${e.label} ${e.detail}` : `docker ${e.label}`)).join(', ')
166
167/** 背景で走らせた取得の失敗は捨てる(表示は前回の値のまま。次の契機で取り直す) */
168const ignore = () => {}
169
170const PUSH_OR_PR = /\b(?:git\s+push|gh\s+pr)\b/
171
172export const register: Register = on => {
173 let isLangFromSettings = false
174
175 on('session.start', async ($, e, next) => {
176 const fromSettings = langFromSetting((await $.settings.read()).language)
177 isLangFromSettings = fromSettings !== null
178 if (fromSettings !== null) await update($, lang, () => fromSettings)
179 await $.command.register({
180 name: 'dashboard',
181 description: 'Open or close the session dashboard pane (attention, session, PR, running servers)',
182 })
183 // セッション開始時点を基準にする。ここで撮ったものは台帳に載らない
184 await refreshLedger($)
185 void refreshPr($).catch(ignore)
186 $.clock.every(LEDGER_EVERY_MS, () => {
187 void (async () => {
188 const items = await refreshLedger($)
189 // 経過時間の表示を進める
190 if (items.length > 0) $.ui.invalidate('ui.render')
191 })().catch(ignore)
192 })
193 // CI が実行中のあいだだけ PR を読み直す
194 $.clock.every(CI_EVERY_MS, () => {
195 void (async () => {
196 const cur = await read($, prView)
197 if (cur.kind === 'pr' && cur.pr.state === 'OPEN' && cur.pr.pending > 0) await refreshPr($)
198 })().catch(ignore)
199 })
200 if ((await $.store.get(OPENED_KEY)) === true) {
201 const l = await labelsOf($)
202 void $.ui.open({ id: PANE, title: l.title }).catch(ignore)
203 }
204
205 return next(e)
206 })
207
208 on('prompt.submit', async ($, e, next) => {
209 const isUserText = e.origin.kind === 'composer' || e.origin.kind === 'bridge'
210 if (isUserText && !isLangFromSettings) await update($, lang, () => (hasJapanese(e.text) ? 'ja' : 'en'))
211
212 return next(e)
213 })
214
215 on('command.run', { command: 'dashboard' }, async $ => {
216 const l = await labelsOf($)
217 if (await isPaneOpen($)) {
218 await $.store.set(OPENED_KEY, false)
219 await $.ui.close({ id: PANE })
220 return { text: l.closedPane }
221 }
222 await $.store.set(OPENED_KEY, true)
223 await refreshLedger($)
224 void refreshPr($).catch(ignore)
225 await $.ui.open({ id: PANE, title: l.title })
226 return { text: l.opened }
227 })
228
229 // ユーザーが閉じたら、次のセッションでは開き直さない
230 on('ui.close', { id: PANE }, async ($, e, next) => {
231 if (e.origin.kind === 'person') await $.store.set(OPENED_KEY, false)
232 return next(e)
233 })
234
235 // 一括停止を止める。フックが失敗したときは素通しにする(ガードが Bash 全体を止めないように)
236 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
237 const kind = massKillOf(e.command)
238 if (kind !== null) return { deny: (await labelsOf($)).deny(kind) }
239
240 const ran = await next(e)
241 void refreshLedger($).catch(ignore)
242 if (PUSH_OR_PR.test(e.command)) void refreshPr($).catch(ignore)
243 return ran
244 // next が呼ばれた後の失敗では、next(e) は決まった結果を返し直すだけで Bash を2回は実行しない
245 }).catch(($, e, next) => next(e))
246
247 // ターンの終わりに、このセッションで起動したものが残っていれば知らせる(同じ顔ぶれでは繰り返さない)
248 on('turn.complete', async ($, e, next) => {
249 const items = await refreshLedger($)
250 const before = new Set(await read($, notified))
251 const keys = items.map(i => i.key)
252 if (keys.some(k => !before.has(k))) $.ui.toast((await labelsOf($)).leftover(describeEntries(items)), { timeoutMs: 8_000 })
253 if (keys.length !== before.size || keys.some(k => !before.has(k))) await update($, notified, () => keys)
254
255 return next(e)
256 })
257
258 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
259 const { Box, Text } = $.ui.resolve(e)
260 const code = (await read($, lang)) ?? 'en'
261 const l = LABELS[code]
262 const { progress, issue } = await readOthers($)
263 const view = await read($, prView)
264 const requests = await read($, reviewRequests)
265 const items = await read($, ledger)
266 const now = await $.clock.now()
267
268 const pr: DashboardPr | null = view.kind === 'pr' ? view.pr : view.kind === 'error' ? view.last : null
269 const isOpenPr = pr !== null && pr.state === 'OPEN'
270
271 type Need = { color: 'error' | 'warning' | 'subtle'; mark: string; text: string }
272 const needs: Need[] = []
273 if (isOpenPr && pr.failed > 0) needs.push({ color: 'error', mark: '✖', text: l.ciFailed(pr.failedNames) })
274 if (isOpenPr && (pr.unresolved ?? 0) > 0) needs.push({ color: 'warning', mark: '●', text: l.unresolved(pr.unresolved ?? 0) })
275 for (const text of issue?.open.slice(0, MAX_OPEN_CRITERIA) ?? []) needs.push({ color: 'subtle', mark: '○', text })
276
277 const Card = ({ title, color, children }: { title: string; color: string; children?: RenderChildren }) => (
278 <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
279 <Text bold color={color}>
280 {title}
281 </Text>
282 {children}
283 </Box>
284 )
285 const Pill = ({ bg, children }: { bg: string; children?: RenderChildren }) => (
286 <Text backgroundColor={bg} color="inverseText" bold>
287 {' '}
288 {children}{' '}
289 </Text>
290 )
291
292 const prRows = () => {
293 if (pr === null) {
294 const text = view.kind === 'none' ? l.noPr : view.kind === 'unknown' ? l.prUnknown : l.prError
295 return [<Text dimColor>{text}</Text>]
296 }
297 const head = (
298 <Text wrap="truncate-end">
299 <Text bold>#{pr.number}</Text>
300 {pr.state === 'MERGED' ? (
301 <Text> <Pill bg="merged">{l.merged}</Pill></Text>
302 ) : pr.state === 'CLOSED' ? (
303 <Text> <Pill bg="subtle">{l.closed}</Pill></Text>
304 ) : pr.isDraft ? (
305 <Text dimColor> {l.draft}</Text>
306 ) : (
307 ''
308 )}{' '}
309 <Text dimColor>{pr.title}</Text>
310 </Text>
311 )
312 if (!isOpenPr) return [head]
313 const total = pr.passed + pr.failed + pr.pending
314 const checks =
315 total === 0 ? (
316 <Text dimColor>{l.noChecks}</Text>
317 ) : (
318 <Text>
319 {pr.passed > 0 && <Pill bg="success">✔ {pr.passed}</Pill>}
320 {pr.passed > 0 && ' '}
321 {pr.failed > 0 && <Pill bg="error">✖ {pr.failed}</Pill>}
322 {pr.failed > 0 && ' '}
323 {pr.pending > 0 && <Pill bg="warning">● {pr.pending}</Pill>}
324 </Text>
325 )
326 return [
327 head,
328 <Box columnGap={2}>
329 {checks}
330 <Text wrap="truncate-end">
331 {l.decision(pr.decision)} · {l.unresolvedShort(pr.unresolved)}
332 </Text>
333 </Box>,
334 ]
335 }
336
337 return (
338 <Box flexDirection="column">
339 {needs.length > 0 && (
340 <Card title={l.needs(needs.length)} color="error">
341 {needs.map(n => (
342 <Text wrap="truncate-end">
343 <Text color={n.color}>{n.mark} </Text>
344 {n.text}
345 </Text>
346 ))}
347 </Card>
348 )}
349 {(progress !== null || issue !== null) && (
350 <Card title={l.session} color="claude">
351 {progress !== null && (
352 <Text wrap="truncate-end">
353 ship-session <Text bold>{l.phase(progress.phase)}</Text>
354 {progress.reviews > 0 ? ` · ${l.round(progress.reviews)}` : ''} <Text dimColor>{l.goal(progress.goal)}</Text>
355 {issue === null && progress.issue !== null ? <Text dimColor> {l.issueRef(progress.issue)}</Text> : ''}
356 </Text>
357 )}
358 {issue !== null && (
359 <Text wrap="truncate-end">
360 #{issue.number} <Text color="success">{bar(issue.done, issue.total, 12)}</Text> <Text bold>
361 {issue.done}/{issue.total}
362 </Text> <Text dimColor>{issue.title}</Text>
363 </Text>
364 )}
365 </Card>
366 )}
367 <Card title={l.pr} color="suggestion">
368 {prRows()}
369 {view.kind === 'error' && pr !== null && <Text color="warning">{l.stale}</Text>}
370 {requests !== null && <Text dimColor>{l.reviewRequests(requests)}</Text>}
371 </Card>
372 <Card title={l.running(items.length)} color="subtle">
373 {items.length === 0 ? (
374 <Text dimColor>{l.nothingRunning}</Text>
375 ) : (
376 items.map(x => (
377 <Text wrap="truncate-end">
378 {x.kind === 'port' ? padCells(x.label, 8) : padCells('docker', 8)}
379 {padCells(x.kind === 'port' ? x.detail : x.label, 18)} <Text dimColor>{elapsed(now - x.since, code)}</Text>
380 </Text>
381 ))
382 )}
383 </Card>
384 </Box>
385 )
386 })
387}
388hooks/observe.ts 288 lines1// session-dashboard の観測と判定(副作用なし)。gh・lsof・docker の出力を読み、台帳の差分と一括停止の判定を行う。
2import type { DashboardBaseline, DashboardEntry, DashboardLang, DashboardPr } from '../types'
3
4// ---------- PR ----------
5
6type Check = {
7 __typename?: string
8 name?: string
9 context?: string
10 status?: string
11 conclusion?: string | null
12 state?: string
13}
14
15const PASSED = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED'])
16const PENDING_STATES = new Set(['PENDING', 'EXPECTED'])
17
18/** statusCheckRollup の1件を passed / failed / pending に振り分ける。 */
19export const classifyCheck = (c: Check): 'passed' | 'failed' | 'pending' => {
20 if (c.__typename === 'StatusContext' || (c.status === undefined && c.state !== undefined)) {
21 const s = c.state ?? ''
22 if (s === 'SUCCESS') return 'passed'
23 return PENDING_STATES.has(s) ? 'pending' : 'failed'
24 }
25 if (c.status !== undefined && c.status !== 'COMPLETED') return 'pending'
26 return PASSED.has(c.conclusion ?? '') ? 'passed' : 'failed'
27}
28
29/** PR の URL から `owner/name` を取る。 */
30export const repoFromUrl = (url: string): string | null => {
31 const m = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/\d+/.exec(url)
32 return m?.[1] ?? null
33}
34
35/** `gh pr view --json number,title,state,isDraft,url,statusCheckRollup,reviewDecision` の出力を読む。 */
36export const prFromJson = (stdout: string): Omit<DashboardPr, 'unresolved'> | null => {
37 try {
38 const j = JSON.parse(stdout) as Record<string, unknown>
39 const repo = typeof j.url === 'string' ? repoFromUrl(j.url) : null
40 if (typeof j.number !== 'number' || repo === null) return null
41 let passed = 0
42 let failed = 0
43 let pending = 0
44 const failedNames: string[] = []
45 for (const c of Array.isArray(j.statusCheckRollup) ? (j.statusCheckRollup as Check[]) : []) {
46 const kind = classifyCheck(c)
47 if (kind === 'passed') passed++
48 else if (kind === 'pending') pending++
49 else {
50 failed++
51 failedNames.push(c.name ?? c.context ?? '?')
52 }
53 }
54 const state = j.state === 'MERGED' || j.state === 'CLOSED' ? j.state : 'OPEN'
55 const d = j.reviewDecision
56 const decision = d === 'APPROVED' || d === 'CHANGES_REQUESTED' || d === 'REVIEW_REQUIRED' ? d : null
57 return {
58 repo,
59 number: j.number,
60 title: typeof j.title === 'string' ? j.title : '',
61 state,
62 isDraft: j.isDraft === true,
63 passed,
64 failed,
65 pending,
66 failedNames,
67 decision,
68 }
69 } catch {
70 return null
71 }
72}
73
74/** `gh pr view` が失敗したとき、PR が無いだけか(読めなかったのではないか)。 */
75export const isNoPr = (stderr: string): boolean =>
76 /no pull requests found|not a git repository|could not determine current branch|no default remote/i.test(stderr)
77
78/** `gh api graphql --paginate --slurp` で全ページを読む(`$endCursor` と `pageInfo` は gh のページ送りの決まり) */
79export const UNRESOLVED_QUERY =
80 'query($owner:String!,$name:String!,$number:Int!,$endCursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}'
81
82/** GraphQL の応答(`--slurp` ならページの配列)から未解決のスレッド数を数える。読めなければ null。 */
83export const unresolvedFromJson = (stdout: string): number | null => {
84 try {
85 const parsed: unknown = JSON.parse(stdout)
86 let count = 0
87 for (const page of Array.isArray(parsed) ? parsed : [parsed]) {
88 const nodes = (page as { data?: { repository?: { pullRequest?: { reviewThreads?: { nodes?: unknown } } } } })?.data?.repository
89 ?.pullRequest?.reviewThreads?.nodes
90 if (!Array.isArray(nodes)) return null
91 count += nodes.filter((n: { isResolved?: boolean }) => n?.isResolved === false).length
92 }
93 return count
94 } catch {
95 return null
96 }
97}
98
99// ---------- 台帳 ----------
100
101export type Seen = Omit<DashboardEntry, 'since'>
102
103/** `lsof -nP -iTCP -sTCP:LISTEN` の出力から、待ち受けポートを1つ1件で読む(IPv4 と IPv6 は1件にまとめる)。 */
104export const portsFromLsof = (stdout: string): Seen[] => {
105 const byKey = new Map<string, Seen>()
106 for (const line of stdout.split('\n').slice(1)) {
107 const cols = line.trim().split(/\s+/)
108 // 末尾は `(LISTEN)`、その前が `*:5173` や `[::1]:5173`
109 const addr = cols[cols.length - 1] === '(LISTEN)' ? cols[cols.length - 2] : undefined
110 const port = addr === undefined ? undefined : /:(\d+)$/.exec(addr)?.[1]
111 if (port === undefined) continue
112 const key = `port:${port}`
113 if (!byKey.has(key)) byKey.set(key, { key, kind: 'port', label: `:${port}`, detail: (cols[0] ?? '?').replace(/\\x20/g, ' ') })
114 }
115 return [...byKey.values()]
116}
117
118/** `docker ps --format '{{.ID}}\t{{.Names}}\t{{.Image}}'` の出力を読む。 */
119export const containersFromDocker = (stdout: string): Seen[] =>
120 stdout
121 .split('\n')
122 .map(line => line.split('\t'))
123 .filter((cols): cols is [string, string, ...string[]] => cols.length >= 2 && cols[0] !== '' && cols[1] !== '')
124 .map(([id, name, image]) => ({ key: `docker:${id}`, kind: 'docker' as const, label: name, detail: image ?? '' }))
125
126/** 1回の観測。取得に失敗した種類は null。 */
127export type Snapshot = { ports: Seen[] | null; containers: Seen[] | null }
128
129/** 基準の無い種類は、今回読めた分を基準にする。 */
130export const fillBaseline = (base: DashboardBaseline | null, snap: Snapshot): DashboardBaseline => ({
131 ports: base?.ports ?? snap.ports?.map(s => s.key) ?? null,
132 containers: base?.containers ?? snap.containers?.map(s => s.key) ?? null,
133})
134
135/**
136 * 基準に無いものだけを台帳に残す。前から載っているものは初めて見た時刻を保つ。
137 * 今回読めなかった種類は、前回の台帳の分をそのまま残す(一時的な失敗で消さない)。
138 */
139export const nextLedger = (base: DashboardBaseline, snap: Snapshot, prev: DashboardEntry[], now: number): DashboardEntry[] => {
140 const prevByKey = new Map(prev.map(e => [e.key, e]))
141 const pick = (kind: 'port' | 'docker', seen: Seen[] | null, known: string[] | null): DashboardEntry[] => {
142 if (seen === null || known === null) return prev.filter(e => e.kind === kind)
143 const skip = new Set(known)
144 return seen.filter(s => !skip.has(s.key)).map(s => ({ ...s, since: prevByKey.get(s.key)?.since ?? now }))
145 }
146 return [...pick('port', snap.ports, base.ports), ...pick('docker', snap.containers, base.containers)]
147}
148
149export const sameLedger = (a: DashboardEntry[], b: DashboardEntry[]): boolean =>
150 a.length === b.length && a.every((e, i) => e.key === b[i]?.key && e.label === b[i]?.label && e.detail === b[i]?.detail)
151
152// ---------- 一括停止のガード ----------
153
154// コマンドの頭: 行頭・; & | ( の後・$( の後と、`bash -c '...'` / `sh -c "..."` の引用符の直後。
155// 続く sudo / env などの前置きと `VAR=値` は飛ばす。`echo "pkill -f x"` のような引数の中は頭とみなさない
156const HEAD = String.raw`(?:^|[;&|(\n]|\$\(|\b(?:ba|z|da|k)?sh\s+(?:-\S+\s+)*-c\s+['"])\s*(?:(?:sudo|command|env|nohup|exec|time)\s+(?:-\S+\s+)*|[A-Za-z_]\w*=\S*\s+)*`
157
158const PKILL_F = new RegExp(`${HEAD}pkill\\b[^;&|\\n]*\\s(?:-[a-zA-Z0-9]*f[a-zA-Z0-9]*|--full)\\b`)
159const KILLALL = new RegExp(`${HEAD}killall\\b`)
160const DOCKER = String.raw`docker\s+(?:container\s+)?`
161// docker stop/kill/rm $(docker ps -q ...)。--filter / -f で絞ったものは通す
162const DOCKER_SUBST = new RegExp(String.raw`${HEAD}${DOCKER}(?:stop|kill|rm)\b[^\n]*\$\(\s*${DOCKER}(?:ps|ls)\b([^)]*)\)`)
163// docker ps -q | xargs docker stop
164const DOCKER_XARGS = new RegExp(String.raw`${HEAD}${DOCKER}(?:ps|ls)\b([^|\n]*)\|\s*xargs\s+(?:-\S+\s+)*${DOCKER}(?:stop|kill|rm)\b`)
165
166const isUnfilteredQuiet = (args: string) => /\s(?:-[a-zA-Z]*q|--quiet\b)/.test(` ${args}`) && !/(?:--filter|\s-f)\b/.test(` ${args}`)
167
168/** 一括停止の形なら、その種類を返す。 */
169export const massKillOf = (command: string): 'pkill' | 'killall' | 'docker' | null => {
170 if (PKILL_F.test(command)) return 'pkill'
171 if (KILLALL.test(command)) return 'killall'
172 const sub = DOCKER_SUBST.exec(command)
173 if (sub !== null && isUnfilteredQuiet(sub[1] ?? '')) return 'docker'
174 const xa = DOCKER_XARGS.exec(command)
175 if (xa !== null && isUnfilteredQuiet(xa[1] ?? '')) return 'docker'
176 return null
177}
178
179// ---------- 表示 ----------
180
181export const hasJapanese = (text: string) => /[-ヿ㐀-鿿]/.test(text)
182
183export const langFromSetting = (language: unknown): DashboardLang | null => {
184 if (typeof language !== 'string' || language.trim() === '') return null
185 return /^(ja|japanese)\b|日本語/i.test(language.trim()) ? 'ja' : 'en'
186}
187
188/** 経過時間を短く書く。 */
189export const elapsed = (ms: number, lang: DashboardLang): string => {
190 const m = Math.max(0, Math.floor(ms / 60_000))
191 if (lang === 'ja') return m < 1 ? '1分未満' : m < 60 ? `${m}分` : `${Math.floor(m / 60)}時間${m % 60}分`
192 return m < 1 ? '<1m' : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60}m`
193}
194
195export const bar = (done: number, total: number, width: number) => {
196 const filled = total === 0 ? 0 : Math.round((done / total) * width)
197 return '█'.repeat(filled) + '░'.repeat(width - filled)
198}
199
200/** 表示幅(全角は2桁)に合わせて末尾を詰める。 */
201export const padCells = (text: string, width: number): string => {
202 let out = ''
203 let used = 0
204 for (const ch of text) {
205 const w = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/.test(ch) ? 2 : 1
206 if (used + w > width) {
207 out = `${out.slice(0, -1)}…`
208 used = width
209 break
210 }
211 out += ch
212 used += w
213 }
214 return out + ' '.repeat(Math.max(0, width - used))
215}
216
217const PHASES = {
218 ja: ['到達点を決める', 'Issue 作成', '実装ループ', '次の一手'],
219 en: ['Choosing the goal', 'Creating the issue', 'Implementation loop', 'Next step'],
220}
221
222export const LABELS = {
223 ja: {
224 title: 'セッション',
225 needs: (n: number) => `要対応 ${n}`,
226 ciFailed: (names: string[]) => `CI 失敗 ${names.join(', ')}`,
227 unresolved: (n: number) => `未解決のレビュースレッド ${n} 件`,
228 session: 'Session',
229 phase: (p: number) => PHASES.ja[p] ?? '?',
230 round: (n: number) => `${n}周目`,
231 goal: (g: string | null) => `到達点 ${g ?? '未確定'}`,
232 issueRef: (n: number) => `Issue #${n}`,
233 pr: 'PR',
234 noPr: 'PR なし',
235 prUnknown: 'PR を読んでいます',
236 prError: 'PR を読めません',
237 stale: '⚠ 読み直しに失敗',
238 draft: 'draft',
239 merged: 'マージ済み',
240 closed: 'クローズ',
241 noChecks: 'CI チェックなし',
242 decision: (d: DashboardPr['decision']) => (d === 'APPROVED' ? '承認済み' : d === 'CHANGES_REQUESTED' ? '修正依頼' : 'レビュー待ち'),
243 unresolvedShort: (n: number | null) => `未解決 ${n ?? '?'}`,
244 reviewRequests: (n: number) => `自分がレビュアーの open PR ${n} 件`,
245 running: (n: number) => `起動中 ${n}`,
246 nothingRunning: 'このセッションで増えたものはありません',
247 opened: 'ダッシュボードを開きました。もう一度 /dashboard で閉じます',
248 closedPane: 'ダッシュボードを閉じました',
249 leftover: (items: string) => `このセッションで起動したものが残っています: ${items}`,
250 deny: (kind: 'pkill' | 'killall' | 'docker') =>
251 kind === 'docker'
252 ? 'docker stop $(docker ps -q) は、ほかのセッションやプロジェクトのコンテナも止めます。止めるコンテナを名前で指定してください(例: docker stop app-db、docker compose down)。'
253 : `${kind === 'pkill' ? 'pkill -f' : 'killall'} は、名前の一致するほかのセッションのプロセスも止めます。ポートか PID を指定して止めてください(例: lsof -ti :5173 | xargs kill、kill 12345)。`,
254 },
255 en: {
256 title: 'Session',
257 needs: (n: number) => `Needs attention ${n}`,
258 ciFailed: (names: string[]) => `CI failed ${names.join(', ')}`,
259 unresolved: (n: number) => `${n} unresolved review thread${n === 1 ? '' : 's'}`,
260 session: 'Session',
261 phase: (p: number) => PHASES.en[p] ?? '?',
262 round: (n: number) => `round ${n}`,
263 goal: (g: string | null) => `goal ${g ?? 'not set'}`,
264 issueRef: (n: number) => `Issue #${n}`,
265 pr: 'PR',
266 noPr: 'No PR',
267 prUnknown: 'Reading the PR',
268 prError: 'Could not read the PR',
269 stale: '⚠ refresh failed',
270 draft: 'draft',
271 merged: 'Merged',
272 closed: 'Closed',
273 noChecks: 'No CI checks',
274 decision: (d: DashboardPr['decision']) => (d === 'APPROVED' ? 'Approved' : d === 'CHANGES_REQUESTED' ? 'Changes requested' : 'Review required'),
275 unresolvedShort: (n: number | null) => `unresolved ${n ?? '?'}`,
276 reviewRequests: (n: number) => `${n} open PR${n === 1 ? '' : 's'} awaiting your review`,
277 running: (n: number) => `Running ${n}`,
278 nothingRunning: 'Nothing new started in this session',
279 opened: 'Dashboard opened. Run /dashboard again to close it',
280 closedPane: 'Dashboard closed',
281 leftover: (items: string) => `Still running from this session: ${items}`,
282 deny: (kind: 'pkill' | 'killall' | 'docker') =>
283 kind === 'docker'
284 ? 'docker stop $(docker ps -q) also stops containers of other sessions and projects. Name the containers to stop (e.g. docker stop app-db, docker compose down).'
285 : `${kind === 'pkill' ? 'pkill -f' : 'killall'} also stops matching processes of other sessions. Stop by port or PID instead (e.g. lsof -ti :5173 | xargs kill, kill 12345).`,
286 },
287}
288types/index.d.ts 64 lines1/** 現在のブランチの PR。`gh pr view --json` から読んだもの。 */
2export type DashboardPr = {
3 /** `owner/name`。PR の URL から取る */
4 repo: string
5 number: number
6 title: string
7 state: 'OPEN' | 'MERGED' | 'CLOSED'
8 isDraft: boolean
9 /** CI のチェックの数(成功・中立・スキップは passed に数える) */
10 passed: number
11 failed: number
12 pending: number
13 /** 失敗したチェックの名前 */
14 failedNames: string[]
15 /** `APPROVED` / `CHANGES_REQUESTED` / `REVIEW_REQUIRED`。判定が無ければ null */
16 decision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null
17 /** 未解決のレビュースレッドの数。読めなかったときは null */
18 unresolved: number | null
19}
20
21/** PR 区画の状態。 */
22export type DashboardPrView =
23 /** まだ読んでいない */
24 | { kind: 'unknown' }
25 /** 現在のブランチに PR が無い(git のリポジトリでない場合も含む) */
26 | { kind: 'none' }
27 /** 読めなかった。前に読めた PR があれば `last` に残す */
28 | { kind: 'error'; last: DashboardPr | null }
29 | { kind: 'pr'; pr: DashboardPr }
30
31/** このセッションで増えた待ち受けポートか docker コンテナ。 */
32export type DashboardEntry = {
33 /** `port:<番号>` か `docker:<コンテナ ID>` */
34 key: string
35 kind: 'port' | 'docker'
36 /** ポートなら `:5173`、コンテナならコンテナ名 */
37 label: string
38 /** ポートならプロセス名、コンテナならイメージ名 */
39 detail: string
40 /** 初めて見た時刻(ミリ秒) */
41 since: number
42}
43
44/** セッション開始時点に既にあったもの。取得に失敗した種類は null(次に読めたときを基準にする) */
45export type DashboardBaseline = { ports: string[] | null; containers: string[] | null }
46
47/** 表示言語 */
48export type DashboardLang = 'ja' | 'en'
49
50declare module 'claude-code' {
51 interface PluginState {
52 'session-dashboard': {
53 pr: DashboardPrView
54 /** ユーザーがレビュアーに指定されている open の PR の件数。読めなければ null */
55 reviewRequests: number | null
56 baseline: DashboardBaseline | null
57 ledger: DashboardEntry[]
58 /** 最後にトーストで知らせた台帳のキー */
59 notified: string[]
60 lang: DashboardLang | null
61 }
62 }
63}
64