SLOPSHOPPER

session-dashboard

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…

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-dashboard
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ PR │ ⏺ Read(src/auth.ts) │ ┃ │ Could not read the PR │ ⎿ Read 6 lines │ ┃ │ 0 open PRs awaiting your review │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ╭──────────────────────────────────────────╮ ⏺ Bash(bun test) │ ┃ │ Running 0 │ ⎿ 3 pass, 1 fail │ ┃ │ Nothing new started in this session │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /dashboard │ ⎿ session-dashboard: Dashboard opened. Run /dashboard again to clo │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
╭──────────────────────────────────────────────────────────╮ │ PR │ │ Could not read the PR │ │ 0 open PRs awaiting your review │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ Running 0 │ │ Nothing new started in this session │ ╰──────────────────────────────────────────────────────────╯
README

English | 日本語

session-dashboard

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.

Install

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.

Open and close

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.

What it shows

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                           │
╰─────────────────────────────────────────────────────────╯
  • Needs attention: names of failed CI checks, the number of unresolved review threads, and acceptance criteria not yet done (up to 3). The section is hidden when there is nothing in it
  • Session: ship-session's phase, review round and goal, and the acceptance progress of the issue acceptance-progress shows. The section is hidden when neither value exists
  • PR: the current branch's PR number, draft flag and title, the counts of passed, failed and pending CI checks (as colored pills), the review decision and the number of unresolved threads. The last row counts the open PRs where you are a requested reviewer
  • For a merged or closed PR it shows only the state, without the CI and review row and without attention items
  • On a branch with no PR it shows "No PR"
  • Running: listening ports (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 out

Long titles and items are cut at the end, so rows stay intact at the docked pane width (about 66 columns).

When it refreshes

WhatWhen
PR and CIAt 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 containersAfter each Bash call, every 30 seconds, when you open the pane, and at the end of each turn
Session valuesWhenever 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.

Leftover servers and containers

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.

Mass-kill guard

The mod blocks these shapes of agent Bash calls and returns, as the reason, examples of stopping by port or name.

BlockedExamples 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.

Decisions taken

  • Other plugins: ship-session and acceptance-progress are not listed under 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 reads
  • Existing bands: the bands ship-session and acceptance-progress draw above the prompt stay as they are
  • Surfaces: the pane is built from Box and Text only, so it draws in the terminal and in the desktop app's Code tab

Language

Taken from Claude Code's language setting, then the language of your latest message, then English.

Out of scope

  • Actions from the pane, such as merging a PR or rerunning CI
  • Keeping the ledger across sessions
  • Details of PRs other than the current branch's

Tests

claude plugin validate plugins/session-dashboard
claude plugin test plugins/session-dashboard
Source 3 files
hooks/dashboard.tsx 388 lines
1// いまのセッションの状況(要対応・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}
388
hooks/observe.ts 288 lines
1// 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}
288
types/index.d.ts 64 lines
1/** 現在のブランチの 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