SLOPSHOPPER

session-outputs

Shows what the current session produced (PRs, branches, Jira, Notion, Slack and other links, edited files, scratchpad files) in a pane (/outputs)

newpanecommandtoastprocesstimer
v0.2.0no licenseupdated 2026-10-14binarythink/claude-marketplace/session-outputs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-outputs
│ ┃ Session outputs ✕ › fix the failing auth test and add an audit log call │ ┃ [ Refresh ] 0 artifacts · updated 14:18:53 │ ┃ No artifacts in this session yet. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /outputs │ ⎿ session-outputs: Session outputs pane opened: 0 artifacts. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session outputs
[ Refresh ] 0 artifacts · updated 14:18:53 No artifacts in this session yet.
README

claude-marketplace

Personal Claude Code mods.

ModCommandDescription
git-graph/git-graphToggles a side pane showing the working directory's git log as a graph
pii-guard—Checks each prompt for personal data before it is sent: blocks Korean unique identifiers, asks before sending phone numbers, emails, cards, accounts, addresses and attachments
session-outputs/outputsToggles a pane listing what the current session produced — PRs, branches, Jira/Notion/Confluence/Slack links, edited files, scratchpad files — grouped into what the session produced (output) and links you gave it (input)
todo-band/todo, /auto-todoToggles a pane showing the session scratchpad's TODO.jsonl (or TODO.md); its todo-plan skill has Claude keep the plan there with dependencies, and /auto-todo has Claude keep it up to date on every request
herdr-screenshot—Its herdr-screenshot skill saves a Herdr pane's screen as a PNG by rendering the pane's ANSI dump in headless Chrome

The panes stay closed until you run the command; running it again closes the pane.

todo-band: TODO.jsonl

The todo-plan skill has Claude write one JSON object per line to the session scratchpad:

{"id":"api","title":"Implement API","status":"doing","deps":["schema"]}
{"id":"deploy","title":"Deploy to dev1","status":"waiting","deps":["api"],"note":"user syncs ArgoCD"}

status is todo, doing (agent running it), waiting (waiting on the user to run or confirm something), done or cancelled. The pane orders items by deps, marks a todo whose deps are not all done as blocked, and flags broken lines, unknown deps and cycles. The full rules are in todo-band/skills/todo-plan/SKILL.md.

The hide done button in the pane's header (or h while the pane has focus) hides done and cancelled items (in TODO.md, - [x] lines) for the rest of the session; press show done (N) to bring them back. show keys (or k) prefixes each TODO.jsonl row with its id, e.g. ▶ [api] Implement API; hide keys turns it off.

/auto-todo [on|off]

Without auto-todo, TODO.jsonl exists only once Claude picks the todo-plan skill for a multi-step task, and later requests in the session go untracked. With /auto-todo on, every prompt you type carries a hidden instruction to keep TODO.jsonl current: work requests (edits, commands, investigation) are appended as items and their statuses updated; questions that need no work get none. If a turn edits files or runs commands but leaves TODO.jsonl untouched, the next prompt also carries a one-time reminder.

  • No argument toggles; the pane header shows auto ● (on) or auto ○ (off).
  • The setting lasts for the session only. To start every session on, set the plugin option autoTodoDefault to true in /config.
  • /auto-todo off stops the instruction and tells Claude once to stop updating the file on its own; the file and the pane stay.

pii-guard: 전송 전 개인정보 확인

프롬프트(붙여넣은 텍스트 포함)와 @경로로 참조한 파일을 전송 전에 검사한다.

처리유형
확인 없이 차단주민등록번호·외국인등록번호(구분자 있는 형식), 여권번호·운전면허번호(키워드 또는 구분자 형식)
"그래도 보낼까요?" 확인구분자 없는 13자리 주민번호 형식, 휴대전화, 이메일, 카드(Luhn), 계좌(키워드), 주소(시·도~번지), 이미지·문서 첨부
  • 대화상자와 차단 사유에는 유형과 건수만 표시하고 감지한 값은 표시하지 않는다.
  • 막힌 프롬프트는 엔진이 입력창에 되돌려 두므로 값을 지우고 다시 보낼 수 있다.
  • 물어볼 수 없는 경우(-p, SDK)나 검사 중 오류가 나면 보내지 않는다(fail-closed).
  • 모델이 도구로 읽는 파일·명령 출력은 검사하지 않는다. 첨부 이미지·문서는 내용 검사 없이 확인만 한다.

herdr-screenshot: pane 화면 PNG 저장

Herdr pane 안에서 "이 화면 캡처해줘" 처럼 요청하면 스킬이 scripts/cap.sh 를 실행한다.

cap.sh [--pane <id>] [--out <png|dir>] [--title <text>] [--lines N]
  • 기본은 현재 pane($HERDR_PANE_ID)의 보이는 화면. --lines N 이면 scrollback 포함
  • 측정 패스로 콘텐츠 크기를 잰 뒤 그 크기로 2배 해상도 스크린샷을 찍으므로 넓은 pane 도 잘리지 않는다
  • 필요: Google Chrome, 폰트 D2KodingLigature Nerd Font (한글 2칸 정렬용)

session-outputs: 세션 산출물 뷰어

/outputs 로 pane 을 열고 닫는다. 현재 진행 중인 세션의 transcript(~/.claude/projects/<slug>/<session id>.jsonl)와 scratchpad 를 읽어 수집 출처별로 두 그룹에 나눠 보여준다. 다른 세션의 산출물은 보이지 않는다.

그룹출처
outputassistant 의 글과 도구 입력(MCP 호출 포함)에 나온 URL, Write·Edit·NotebookEdit 로 만든 파일, git·gh 명령의 입출력, 세션 중 checkout 된 branch, 세션 scratchpad 의 파일
input사용자 메시지의 글에 나온 URL
  • 같은 항목이 두 그룹에서 나오면 처음 나온 쪽에만 보인다. 사용자가 준 참고 링크를 assistant 가 다시 언급해도 input 에 남는다
  • 그룹 안에서는 세션에 처음 나온 순서이고, 파일과 링크가 섞인다. scratchpad 파일은 output 의 맨 끝에 온다
  • 각 행은 type(PR · Branch · Jira · Confluence · Notion · Slack · GitHub · Web · File · Scratch), name, path 순이다. 파일의 name 은 파일 이름, path 는 worktree 기준 폴더(root 는 .)이고, 링크의 path 는 URL 이다. path 는 흐리게 보인다. 지금 없는 파일은 (deleted) 로 표시한다
  • git·gh 명령에서는 입력·출력 모두 GitHub 링크(PR · Branch · GitHub)만 남긴다. 출력은 gh pr create·gh issue create·gh release create·git push 의 것만 읽는다. gh pr view·gh api·git log 같은 조회 명령의 출력과 PR 본문(--body)의 Jira·Notion 링크는 참고 자료라 제외한다
  • branch 는 git checkout -b·git switch -c·git worktree add -b·git push -u 명령, git push 출력의 pull/new/<branch> URL 에서 찾는다
  • scratchpad 는 /tmp 아래라 재부팅 후에는 사라진다
  • r 로 다시 읽는다. 열려 있으면 10초마다 갱신한다
  • pane 에 포커스가 있을 때 j/k 로 한 item 씩 이동하고, c 는 선택한 item 의 이름, p 는 경로(파일은 절대 경로, 링크는 URL, URL 없는 branch 는 이름)를 클립보드에 복사한다
  • 4 MiB 가 넘는 transcript 는 tail -c 로 나눠 읽는다 ($.fs.read 상한)
  • 링크는 path 칸의 URL 을 클릭하면 열린다 (OSC 8 hyperlink). 파일은 OSC 8 을 못 그리는 터미널에서 file:// URL 이 덧붙지 않도록 링크를 걸지 않는다. p 로 절대 경로를 복사한다
  • attribution 링크(claude.ai/code/session_…, claude.com/claude-code), api.* 엔드포인트, Read·WebFetch 등 도구 결과 속 URL 은 잡음이라 제외한다

Install

In a Claude Code terminal session:

/plugin install git-graph --marketplace binarythink/claude-marketplace
/plugin install todo-band --marketplace binarythink/claude-marketplace
/plugin install pii-guard --marketplace binarythink/claude-marketplace
/plugin install herdr-screenshot --marketplace binarythink/claude-marketplace
/plugin install session-outputs --marketplace binarythink/claude-marketplace

Answer y to add the marketplace, then pick a scope.

Or from the shell:

claude plugin marketplace add binarythink/claude-marketplace
claude plugin install git-graph@binarythink
claude plugin install todo-band@binarythink
claude plugin install pii-guard@binarythink
claude plugin install herdr-screenshot@binarythink
claude plugin install session-outputs@binarythink

Update

Bump version in the mod's .claude-plugin/plugin.json when publishing a change, then:

claude plugin marketplace update binarythink
claude plugin update git-graph@binarythink

Develop

claude --plugin-dir ./git-graph   # loads the working copy, reloads on save
claude plugin validate .
claude plugin test ./git-graph
Source 3 files
hooks/register.tsx 363 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, UiPressArgument } from 'claude-code'
3
4import type { Artifact, ArtifactIndex, ArtifactKind, ArtifactSource } from '../types'
5import type { ParsedTranscript } from './parse'
6import {
7  KIND_LABELS, clockTime, dirOf, displayPath, parseTranscript, parseWorktrees, slugOf, spanOf,
8} from './parse'
9
10const PANE = 'session-outputs'
11const TITLE = 'Session outputs'
12const POLL_MS = 10_000
13const TMP_ROOTS = ['/private/tmp', '/tmp']
14const SCRATCH_DEPTH = 2
15// $.fs.read 와 $.process.run 의 stdout 은 4 MiB 까지라, 그보다 큰 transcript 는 나눠 읽는다.
16const READ_LIMIT = 4 * 1024 * 1024
17
18const indexAtom = atom({ plugin: 'session-outputs', key: 'index' } as const, {} as ArtifactIndex)
19// j/k 로 고르는 item 의 순번. 화면에 그린 순서(출처별 묶음) 기준이다.
20const selectedAtom = atom({ plugin: 'session-outputs', key: 'selected' } as const, 0)
21
22// 그룹을 그리는 순서와 제목
23const GROUPS: { source: ArtifactSource; title: string }[] = [
24  { source: 'output', title: 'output' },
25  { source: 'input', title: 'input' },
26]
27
28const KIND_COLORS: Record<ArtifactKind, string> = {
29  pr: 'green',
30  branch: 'cyan',
31  jira: 'blue',
32  notion: 'white',
33  confluence: 'blue',
34  slack: 'magenta',
35  github: 'gray',
36  web: 'gray',
37  file: 'yellow',
38  scratch: 'yellow',
39}
40
41// transcript 는 수 MB 라 mtime·size 가 그대로면 다시 읽지 않는다. reload 하면 비워진다.
42let cached: { path: string; mtimeMs: number; size: number; parsed: ParsedTranscript } | undefined
43let isRefreshing = false
44
45const paneOf = async ($: EngineInterface) =>
46  (await $.ui.panes()).find(pane => pane.id === PANE)
47
48// 이 저장소의 worktree 들. 첫 줄이 main worktree 다. git 이 아니면 세션 root 하나.
49// 파일 경로를 줄이는 데 쓰고, transcript·scratchpad 폴더 이름(slug) 후보이기도 하다.
50async function worktreeRoots($: EngineInterface): Promise<string[]> {
51  const root = await $.session.root()
52  try {
53    const { exitCode, stdout } = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: root, timeoutMs: 10_000 })
54    const roots = exitCode === 0 ? parseWorktrees(stdout) : []
55    if (roots.length > 0) return roots
56  } catch {}
57  return [root]
58}
59
60// <tmp>/claude-<uid> 폴더들. 세션 scratchpad 는 그 아래 <project slug>/<session id>/scratchpad 에 있다.
61async function tmpBases($: EngineInterface): Promise<string[]> {
62  const custom = await $.env.get('CLAUDE_CODE_TMPDIR')
63  // macOS 의 /tmp 는 /private/tmp 의 symlink 라 실제 경로로 한 번만 센다.
64  const bases = new Map<string, string>()
65  for (const root of custom ? [custom, ...TMP_ROOTS] : TMP_ROOTS) {
66    const entries = await $.fs.list(root).catch(() => [])
67    for (const one of entries) {
68      if (one.kind !== 'dir' || !one.name.startsWith('claude-')) continue
69      const base = `${root}/${one.name}`
70      const real = (await $.fs.stat(base, { resolve: true }).catch(() => undefined))?.realPath ?? base
71      if (!bases.has(real)) bases.set(real, base)
72    }
73  }
74  return [...bases.values()]
75}
76
77async function scratchFiles($: EngineInterface, dir: string, prefix = '', depth = 0): Promise<string[]> {
78  const entries = await $.fs.list(dir).catch(() => [])
79  const files: string[] = []
80  for (const one of entries.sort((a, b) => a.name.localeCompare(b.name))) {
81    if (one.kind === 'file') files.push(`${prefix}${one.name}`)
82    else if (one.kind === 'dir' && depth < SCRATCH_DEPTH) {
83      files.push(...await scratchFiles($, `${dir}/${one.name}`, `${prefix}${one.name}/`, depth + 1))
84    }
85  }
86  return files
87}
88
89// tail -c +<offset> 의 앞 4 MiB 에서 마지막 줄바꿈까지만 쓰고, 그 UTF-8 바이트 수만큼 offset 을 넘긴다.
90// 잘린 끝 줄(멀티바이트 글자가 잘렸을 수도 있다)은 다음 조각에서 처음부터 다시 읽힌다.
91async function readLarge($: EngineInterface, path: string): Promise<string> {
92  const encoder = new TextEncoder()
93  const chunks: string[] = []
94  let offset = 0
95  for (;;) {
96    const { exitCode, stdout, stderr, isStdoutTruncated } =
97      await $.process.run(['tail', '-c', `+${offset + 1}`, path], { timeoutMs: 30_000 })
98    if (exitCode !== 0) throw new Error(stderr.trim() || `tail exited with ${exitCode}`)
99    if (!isStdoutTruncated) {
100      chunks.push(stdout)
101      return chunks.join('')
102    }
103    const end = stdout.lastIndexOf('\n')
104    if (end < 0) throw new Error(`${path}: a line over 4 MiB`)
105    const whole = stdout.slice(0, end + 1)
106    chunks.push(whole)
107    offset += encoder.encode(whole).length
108  }
109}
110
111async function readTranscript($: EngineInterface, path: string, mtimeMs: number, size: number) {
112  if (cached && cached.path === path && cached.mtimeMs === mtimeMs && cached.size === size) return cached.parsed
113  const text = size < READ_LIMIT ? await $.fs.read(path) : await readLarge($, path)
114  const parsed = parseTranscript(text)
115  cached = { path, mtimeMs, size, parsed }
116  return parsed
117}
118
119// 현재 세션의 transcript 와 scratchpad 만 읽는다.
120// transcript 는 <config>/projects/<세션을 시작한 폴더의 slug>/<session id>.jsonl 이라, 시작 폴더 후보를 차례로 본다.
121async function buildIndex($: EngineInterface): Promise<ArtifactIndex> {
122  const home = await $.env.get('HOME')
123  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
124  if (!configDir) return { error: 'HOME is not set', loadedAt: Date.now() }
125
126  const [id, root, cwd, roots, bases] =
127    await Promise.all([$.session.id(), $.session.root(), $.session.cwd(), worktreeRoots($), tmpBases($)])
128  const slugs = [...new Set([root, cwd, ...roots].map(slugOf))]
129
130  let transcript: { path: string; mtimeMs: number; size: number } | undefined
131  for (const slug of slugs) {
132    const path = `${configDir}/projects/${slug}/${id}.jsonl`
133    const stat = await $.fs.stat(path).catch(() => undefined)
134    if (stat?.kind === 'file') {
135      transcript = { path, mtimeMs: stat.mtimeMs, size: stat.size }
136      break
137    }
138  }
139  // 새 세션은 첫 프롬프트를 보내기 전까지 transcript 가 없다. 오류가 아니라 아직 산출물이 없는 상태다.
140  if (!transcript) return { loadedAt: Date.now() }
141
142  const parsed = await readTranscript($, transcript.path, transcript.mtimeMs, transcript.size)
143  const artifacts: Artifact[] = []
144  for (const one of parsed.artifacts) {
145    if (one.kind !== 'file' || !one.path) {
146      artifacts.push(one)
147      continue
148    }
149    // scratchpad 에 쓴 파일은 아래 Scratch 로 보인다.
150    if (one.path.includes(`/${id}/scratchpad/`)) continue
151    const exists = await $.fs.exists(one.path).catch(() => false)
152    artifacts.push({
153      ...one,
154      displayPath: displayPath(one.path, roots, home),
155      href: exists ? `file://${one.path}` : undefined,
156      isGone: !exists,
157    })
158  }
159  scratch: for (const base of bases) {
160    for (const slug of slugs) {
161      const pad = `${base}/${slug}/${id}/scratchpad`
162      if (!(await $.fs.exists(pad).catch(() => false))) continue
163      for (const name of await scratchFiles($, pad)) {
164        const path = `${pad}/${name}`
165        artifacts.push({
166          kind: 'scratch',
167          label: name.split('/').pop() ?? name,
168          href: `file://${path}`,
169          path,
170          displayPath: displayPath(path, roots, home),
171          source: 'output',
172        })
173      }
174      break scratch
175    }
176  }
177
178  const prompt = parsed.firstPrompt
179  return {
180    session: {
181      id,
182      title: parsed.title ?? parsed.aiTitle ?? (prompt ? (prompt.length > 60 ? `${prompt.slice(0, 59)}…` : prompt) : '(untitled)'),
183      cwd: parsed.cwd,
184      startedAt: parsed.startedAt,
185      endedAt: parsed.endedAt ?? transcript.mtimeMs,
186      artifacts,
187    },
188    loadedAt: Date.now(),
189  }
190}
191
192async function refresh($: EngineInterface): Promise<ArtifactIndex | undefined> {
193  if (isRefreshing) return undefined
194  isRefreshing = true
195  try {
196    let index: ArtifactIndex
197    try {
198      index = await buildIndex($)
199    } catch (err) {
200      index = { ...(await read($, indexAtom)), error: String(err), loadedAt: Date.now() }
201    }
202    await update($, indexAtom, () => index)
203    return index
204  } finally {
205    isRefreshing = false
206  }
207}
208
209// 화면에 그리는 순서: 출처별로 묶고, 한 그룹 안에서는 세션에 처음 나온 순서(scratchpad 파일은 맨 끝).
210const orderOf = (artifacts: Artifact[]) =>
211  GROUPS.flatMap(group => artifacts.filter(one => one.source === group.source))
212
213// 행의 path 칸: 파일은 줄인 경로, 링크는 URL. URL 없는 branch 는 비운다.
214// 파일은 name 칸이 이미 파일 이름이라 폴더만 보인다.
215const locationOf = (one: Artifact) => (one.displayPath === undefined ? one.href : dirOf(one.displayPath))
216
217// p 가 복사하는 값: 파일은 절대 경로, 링크는 URL, URL 없는 branch 는 이름.
218const pathOf = (one: Artifact) => one.path ?? one.href ?? one.label
219
220const itemKey = (n: number) => `item-${n}`
221
222const summaryOf = (index: ArtifactIndex) => {
223  const count = index.session?.artifacts.length ?? 0
224  return `${count} artifact${count === 1 ? '' : 's'}`
225}
226
227export const register: Register = on => {
228  on('session.start', async ($, e, next) => {
229    await $.command.register({
230      name: 'outputs',
231      description: 'Toggle the pane showing what this session produced: PRs, branches, Jira/Notion/Slack links, files',
232    })
233    // reload 로 session.start 가 다시 불려도 pane 이 열려 있을 때만 읽는다.
234    if (await paneOf($).catch(() => undefined)) void refresh($).catch(() => undefined)
235    $.clock.every(POLL_MS, () => {
236      void (async () => {
237        if (await paneOf($)) await refresh($)
238      })().catch(() => undefined)
239    })
240
241    return next(e)
242  })
243
244  // /outputs 는 pane 을 열고 닫는 toggle 이다. 세션 시작 시 자동으로 열지 않는다.
245  // 다른 탭 뒤에 열려 있으면 닫지 않고 앞으로 가져온다.
246  on('command.run', { command: 'outputs' }, async $ => {
247    const pane = await paneOf($)
248    if (pane?.isShown) {
249      await $.ui.close({ id: PANE })
250      return { text: 'Session outputs pane closed.' }
251    }
252    if (pane) {
253      // 열린 id 를 다시 open 하면 제목만 바뀌고 탭은 그대로라, 닫았다 열어 맨 앞 탭으로 올린다.
254      await $.ui.close({ id: PANE })
255      await $.ui.open({ id: PANE, title: TITLE })
256      void refresh($).catch(() => undefined)
257      return { text: 'Session outputs pane brought to front.' }
258    }
259    const index = await refresh($) ?? await read($, indexAtom)
260    await $.ui.open({ id: PANE, title: TITLE })
261
262    return { text: index.error ? `Session outputs: ${index.error}` : `Session outputs pane opened: ${summaryOf(index)}.` }
263  })
264
265  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
266    const { Box, Text, Button, Link } = $.ui.resolve(e)
267    const index = await read($, indexAtom)
268    const session = index.session
269    const items = orderOf(session?.artifacts ?? [])
270    // 다시 읽어 item 이 줄었으면 마지막 item 에 머문다.
271    const selected = Math.min(await read($, selectedAtom), Math.max(0, items.length - 1))
272
273    // 키를 연달아 누르면 다시 그리기 전에 다음 press 가 오므로, 그린 시점의 selected 가 아니라 atom 의 지금 값을 쓴다.
274    const clamp = (n: number) => Math.min(items.length - 1, Math.max(0, n))
275    const move = (delta: number) => async () => {
276      if (items.length === 0) return
277      let next = 0
278      await update($, selectedAtom, current => (next = clamp(clamp(current) + delta)))
279      await $.ui.scroll({ to: { key: itemKey(next) }, in: PANE }).catch(() => undefined)
280    }
281    const copy = (what: 'name' | 'path') => async (press: UiPressArgument) => {
282      const item = items[clamp(await read($, selectedAtom))]
283      if (!item) return
284      const text = what === 'name' ? item.label : pathOf(item)
285      const { isCopied } = await $.ui.copy({ text, surface: press.surface })
286      await $.ui.toast(isCopied ? `Copied ${what}: ${text}` : `Could not copy the ${what}`)
287    }
288
289    const name = (one: Artifact) => one.isGone
290      ? <Text dimColor strikethrough>{`${one.label} (deleted)`}</Text>
291      : <Text>{one.label}</Text>
292    // path 칸은 흐리게 그린다. 링크는 URL 칸 자체를 Link 로 둔다. OSC 8 을 못 그리는 터미널은
293    // Link 글자 뒤에 URL 을 덧붙이므로, 글자 없이 href 만 주어야 URL 이 한 번만 보인다.
294    // 파일은 같은 이유로 Link 를 쓰지 않는다(폴더 뒤에 file:// URL 이 붙는다).
295    const location = (one: Artifact) => {
296      const text = locationOf(one)
297      if (!text) return null
298      return (
299        <Text dimColor>
300          {'  '}
301          {one.displayPath === undefined && one.href ? <Link href={one.href} /> : text}
302        </Text>
303      )
304    }
305
306    // 행은 type, name, path 순이다. type 칸은 이 pane 에 보이는 가장 긴 종류 이름에 맞춘다.
307    const typeWidth = Math.max(0, ...items.map(one => KIND_LABELS[one.kind].length))
308    let n = 0
309    const groups = GROUPS.flatMap(group => {
310      const rows = items.filter(one => one.source === group.source)
311      if (rows.length === 0) return []
312      return [(
313        <Box key={`g-${group.source}`} flexDirection="column" marginTop={1}>
314          <Text bold>{`▸ ${group.title} (${rows.length})`}</Text>
315          {rows.map(one => {
316            const at = n++
317            const isSelected = at === selected
318            return (
319              <Box key={itemKey(at)}>
320                <Text wrap="truncate-end">
321                  <Text color="cyan" bold>{isSelected ? '› ' : '  '}</Text>
322                  <Text color={KIND_COLORS[one.kind]}>{`${KIND_LABELS[one.kind].padEnd(typeWidth)}  `}</Text>
323                  {isSelected ? <Text inverse>{name(one)}</Text> : name(one)}
324                  {location(one)}
325                </Text>
326              </Box>
327            )
328          })}
329        </Box>
330      )]
331    })
332
333    const loaded = index.loadedAt ? ` · updated ${clockTime(index.loadedAt)}` : ''
334
335    return (
336      <Box flexDirection="column">
337        <Box flexDirection="row" gap={1}>
338          <Button key="refresh" label="Refresh" hotkey="r" onPress={() => void refresh($)} />
339          <Text dimColor wrap="truncate-end">{`${summaryOf(index)}${loaded}`}</Text>
340        </Box>
341        {items.length > 0 && (
342          <Box flexDirection="row" gap={2}>
343            <Button key="next" label="Next" hotkey="j" plain onPress={() => void move(1)()} />
344            <Button key="prev" label="Prev" hotkey="k" plain onPress={() => void move(-1)()} />
345            <Button key="copy-name" label="Copy name" hotkey="c" plain onPress={press => void copy('name')(press)} />
346            <Button key="copy-path" label="Copy path" hotkey="p" plain onPress={press => void copy('path')(press)} />
347          </Box>
348        )}
349        {session && (
350          <Text wrap="truncate-end">
351            <Text bold>{session.title}</Text>
352            <Text dimColor>{`  ${[spanOf(session.startedAt, session.endedAt), session.id.slice(0, 8)].filter(Boolean).join(' · ')}`}</Text>
353          </Text>
354        )}
355        {index.error && <Text color="red">{index.error}</Text>}
356        {!index.error && !index.loadedAt && <Text dimColor>Reading the session transcript…</Text>}
357        {index.loadedAt && !index.error && items.length === 0 && <Text dimColor>No artifacts in this session yet.</Text>}
358        {groups}
359      </Box>
360    )
361  })
362}
363
hooks/parse.ts 337 lines
1import type { Artifact, ArtifactKind, ArtifactSource } from '../types'
2
3// URL 하나를 분류한 결과. 어디서 나왔는지(source)는 수집하는 쪽이 붙인다.
4export type Link = Omit<Artifact, 'source'>
5
6export const KIND_LABELS: Record<ArtifactKind, string> = {
7  pr: 'PR',
8  branch: 'Branch',
9  jira: 'Jira',
10  notion: 'Notion',
11  confluence: 'Confluence',
12  slack: 'Slack',
13  github: 'GitHub',
14  web: 'Web',
15  file: 'File',
16  scratch: 'Scratch',
17}
18
19// 파일을 쓰는 도구. 입력의 경로가 산출물이고, 입력 본문의 URL 은 보지 않는다(README 의 설치 URL 같은 잡음).
20const FILE_TOOLS: Record<string, string> = {
21  Write: 'file_path',
22  Edit: 'file_path',
23  MultiEdit: 'file_path',
24  NotebookEdit: 'notebook_path',
25}
26
27// 괄호·따옴표·꺾쇠·백슬래시·비 ASCII 에서 끊는다. 마크다운 링크와 JSON 문자열 안의 URL 도 잡힌다.
28const URL_RE = /https?:\/\/[A-Za-z0-9\-._~:/?#@!$&*+,;=%]+/g
29const TRAILING_RE = /[.,;:!?*]+$/
30
31// 모든 커밋·PR 에 붙는 attribution 과 API 엔드포인트는 산출물이 아니다.
32// '…slack', '…atlassian' 처럼 글에서 잘린 host 도 버린다.
33const isNoise = (url: URL) =>
34  url.host.startsWith('api.')
35  || /\.(slack|atlassian|notion|github)$/.test(url.hostname)
36  || url.host === 'localhost'
37  || (url.host === 'claude.com' && url.pathname.startsWith('/claude-code'))
38  || (url.host === 'code.claude.com' && url.pathname.length <= 1)
39  || (url.host === 'claude.ai' && url.pathname.startsWith('/code/session_'))
40
41const PROTECTED_BRANCHES = new Set(['main', 'master', 'HEAD', 'develop'])
42
43export const isWorkBranch = (name: string | undefined): name is string =>
44  !!name && !name.startsWith('-') && !PROTECTED_BRANCHES.has(name)
45
46const decode = (part: string) => {
47  try {
48    return decodeURIComponent(part.replace(/\+/g, ' '))
49  } catch {
50    return part
51  }
52}
53
54// notion.so/<workspace>/<Title-Words>-<32 hex id> 에서 제목 부분만 남긴다.
55const notionTitle = (path: string) => {
56  const last = decode(path.split('/').filter(Boolean).pop() ?? '')
57  const title = last.replace(/-?[0-9a-f]{32}$/i, '').replace(/-/g, ' ').trim()
58  return title || 'Notion page'
59}
60
61export const extractUrls = (text: string): string[] =>
62  (text.match(URL_RE) ?? []).map(raw => raw.replace(TRAILING_RE, ''))
63
64// URL 하나를 산출물로 분류한다. 잡음이거나 URL 이 아니면 undefined.
65export function classifyUrl(raw: string): Link | undefined {
66  let url: URL
67  try {
68    url = new URL(raw)
69  } catch {
70    return undefined
71  }
72  if (!/\.[a-z]{2,}$/i.test(url.hostname) || raw.includes('$') || isNoise(url)) return undefined
73
74  const host = url.hostname.toLowerCase()
75  const parts = url.pathname.split('/').filter(Boolean)
76
77  if (host === 'github.com' && parts.length >= 2) {
78    const [owner, repo, section, ...rest] = parts
79    const base = `https://github.com/${owner}/${repo}`
80    if (section === 'pull' && rest[0] === 'new' && rest.length > 1) {
81      const branch = decode(rest.slice(1).join('/'))
82      return { kind: 'branch', label: branch, href: `${base}/tree/${rest.slice(1).join('/')}` }
83    }
84    if (section === 'pull' && /^\d+$/.test(rest[0] ?? '')) {
85      return { kind: 'pr', label: `${owner}/${repo}#${rest[0]}`, href: `${base}/pull/${rest[0]}` }
86    }
87    return { kind: 'github', label: parts.join('/'), href: `https://github.com/${parts.join('/')}` }
88  }
89
90  if (host.endsWith('.atlassian.net')) {
91    const key = url.pathname.match(/\/browse\/([A-Z][A-Z0-9_]+-\d+)/)?.[1]
92      ?? url.searchParams.get('selectedIssue') ?? undefined
93    if (key) return { kind: 'jira', label: key, href: `https://${host}/browse/${key}` }
94    if (parts[0] === 'wiki') {
95      const title = decode(parts[parts.length - 1] ?? '')
96      return { kind: 'confluence', label: /^\d+$/.test(title) ? `page ${title}` : title, href: raw }
97    }
98  }
99
100  if (host === 'notion.so' || host.endsWith('.notion.so') || host.endsWith('.notion.site') || host === 'app.notion.com') {
101    return { kind: 'notion', label: notionTitle(url.pathname), href: raw }
102  }
103
104  if (host.endsWith('.slack.com') && parts[0] === 'archives' && parts[1]) {
105    const ts = parts[2]?.match(/^p(\d{10})(\d*)$/)
106    const at = ts ? ` · ${shortTime(Number(ts[1]) * 1000)}` : ''
107    return { kind: 'slack', label: `${host.split('.')[0]} ${parts[1]}${at}`, href: raw }
108  }
109
110  return { kind: 'web', label: `${host}${url.pathname === '/' ? '' : url.pathname}`, href: raw }
111}
112
113// Bash 명령에서 새로 만들거나 push 한 branch 이름을 찾는다.
114const BRANCH_RES = [
115  /git\s+(?:-C\s+\S+\s+)?(?:checkout|switch)\s+(?:[^\n;&|]*?\s)?-[bBcC]\s+([^\s;&|]+)/g,
116  /git\s+(?:-C\s+\S+\s+)?worktree\s+add\s+[^\n;&|]*?-[bB]\s+([^\s;&|]+)/g,
117  /git\s+(?:-C\s+\S+\s+)?push\s+[^\n;&|]*?(?:-u|--set-upstream)\s+[^\s;&|-]\S*\s+([^\s;&|:]+)/g,
118]
119
120export function branchesFromCommand(command: string): string[] {
121  const found: string[] = []
122  for (const re of BRANCH_RES) {
123    for (const match of command.matchAll(re)) {
124      const name = match[1]?.replace(/^['"]|['"]$/g, '')
125      if (isWorkBranch(name)) found.push(name)
126    }
127  }
128  return found
129}
130
131export type ParsedTranscript = {
132  // 사용자가 붙인 제목(/rename). 없으면 aiTitle, 그것도 없으면 firstPrompt 를 쓴다.
133  title?: string
134  aiTitle?: string
135  firstPrompt?: string
136  startedAt?: number
137  endedAt?: number
138  cwd?: string
139  // 링크·branch·file 을 처음 수집된 순서대로. 같은 항목이 다시 나와도 처음 출처를 유지한다.
140  // file 의 path 는 절대 경로이고, displayPath·href·isGone 은 register 가 채운다.
141  artifacts: Artifact[]
142}
143
144type Block = {
145  type?: string
146  text?: string
147  id?: string
148  name?: string
149  input?: Record<string, unknown>
150  tool_use_id?: string
151  content?: unknown
152}
153
154const REMINDER_RE = /<system-reminder>[\s\S]*?<\/system-reminder>/g
155// 붙여넣은 텍스트는 <pasted_content id="..."> 로 감싸여 저장된다. 태그만 걷고 내용은 남긴다.
156const PASTED_TAG_RE = /<\/?pasted_content\b[^>]*>/g
157
158const resultText = (content: unknown): string => {
159  if (typeof content === 'string') return content
160  if (!Array.isArray(content)) return ''
161  return content.map(one => (typeof one?.text === 'string' ? one.text : '')).join('\n')
162}
163
164// PR·branch URL 을 남기는 명령. 다른 Bash 명령(grep, cat, 스크립트)의 입출력에 든 URL 은 잡음이다.
165const GIT_COMMAND_RE = /(^|[\s;&|(])(gh|git)\s/
166// 출력에 새로 만든 것의 URL 을 찍는 git·gh 명령. gh pr view·gh api·git log 같은 조회 명령의 출력은
167// PR 본문·커밋 메시지에 든 참고 링크라 보지 않는다.
168const CREATING_COMMAND_RE =
169  /(^|[\s;&|(])(gh\s+(pr|issue|release)\s+create|git\s+(-C\s+\S+\s+)?push)(\s|$)/
170// git·gh 명령의 입출력에서는 GitHub 링크만 남긴다. PR 본문(--body)이나 출력에 든 Jira·Notion 링크는 참고 자료다.
171const GIT_KINDS = new Set<ArtifactKind>(['pr', 'branch', 'github'])
172
173// 세션 transcript(JSONL) 한 파일에서 제목·기간·링크·파일·branch 를 모은다.
174// 링크는 사용자·assistant 의 글, Bash 가 아닌 도구 입력, git·gh 명령의 입력과 산출물을 만드는 명령의 출력에서만 찾는다.
175// 다른 도구 결과(Read, WebFetch, MCP)는 잡음이 많아 보지 않는다.
176// transcript 의 gitBranch 는 그때 checkout 된 branch 일 뿐이라, 다른 산출물이 있는 세션에서만 남긴다.
177export function parseTranscript(text: string): ParsedTranscript {
178  const parsed: ParsedTranscript = { artifacts: [] }
179  // 링크는 href, branch 는 이름, file 은 경로로 같은 항목을 가린다.
180  const seen = new Map<string, Artifact>()
181  const creatingToolIds = new Set<string>()
182  const checkedOut: string[] = []
183
184  const add = (key: string, artifact: Artifact) => {
185    const known = seen.get(key)
186    if (known) {
187      // git push 로 URL 이 나중에 생긴 branch 는 처음 출처를 둔 채 URL 만 채운다.
188      known.href ??= artifact.href
189      return
190    }
191    seen.set(key, artifact)
192    parsed.artifacts.push(artifact)
193  }
194  const addUrls = (text: string, source: ArtifactSource, kinds?: Set<ArtifactKind>) => {
195    for (const raw of extractUrls(text)) {
196      const link = classifyUrl(raw)
197      if (!link || (kinds && !kinds.has(link.kind))) continue
198      if (link.kind === 'branch') addBranch(link.label, source, link.href)
199      else add(`link:${link.href ?? link.label}`, { ...link, source })
200    }
201  }
202  const addBranch = (name: string | undefined, source: ArtifactSource, href?: string) => {
203    if (isWorkBranch(name)) add(`branch:${name}`, { kind: 'branch', label: name, href, source })
204  }
205  const addFile = (path: unknown) => {
206    if (typeof path !== 'string' || path === '') return
207    add(`file:${path}`, { kind: 'file', label: path.split('/').pop() || path, path, source: 'output' })
208  }
209
210  for (const line of text.split('\n')) {
211    if (line.includes('"custom-title"') || line.includes('"ai-title"')) {
212      try {
213        const record = JSON.parse(line)
214        const custom = record.type === 'custom-title' ? record.customTitle : undefined
215        const ai = record.type === 'ai-title' ? record.aiTitle : undefined
216        if (typeof custom === 'string' && custom.trim()) parsed.title = custom.trim()
217        if (typeof ai === 'string' && ai.trim()) parsed.aiTitle = ai.trim()
218      } catch {}
219      continue
220    }
221    if (!line.includes('"type":"user"') && !line.includes('"type":"assistant"')) continue
222
223    let record: {
224      type?: string
225      isMeta?: boolean
226      timestamp?: string
227      cwd?: string
228      gitBranch?: string
229      message?: { content?: unknown }
230    }
231    try {
232      record = JSON.parse(line)
233    } catch {
234      continue
235    }
236    if (record.type !== 'user' && record.type !== 'assistant') continue
237
238    const at = record.timestamp ? Date.parse(record.timestamp) : NaN
239    if (!Number.isNaN(at)) {
240      parsed.startedAt ??= at
241      parsed.endedAt = at
242    }
243    if (record.cwd) parsed.cwd = record.cwd
244    if (isWorkBranch(record.gitBranch) && !checkedOut.includes(record.gitBranch)) checkedOut.push(record.gitBranch)
245    if (record.isMeta) continue
246
247    const content = record.message?.content
248    const blocks: Block[] = typeof content === 'string'
249      ? [{ type: 'text', text: content }]
250      : Array.isArray(content) ? content : []
251
252    for (const block of blocks) {
253      if (block.type === 'text' && typeof block.text === 'string') {
254        const body = block.text.replace(REMINDER_RE, '').replace(PASTED_TAG_RE, '').trim()
255        if (record.type === 'user' && parsed.firstPrompt === undefined && body && !body.startsWith('<')) {
256          parsed.firstPrompt = body.split('\n')[0]!.trim()
257        }
258        addUrls(body, record.type === 'user' ? 'input' : 'output')
259      } else if (block.type === 'tool_use' && block.name) {
260        const input = block.input ?? {}
261        const pathKey = FILE_TOOLS[block.name]
262        if (pathKey) {
263          addFile(input[pathKey])
264          continue
265        }
266        if (block.name === 'Bash') {
267          const command = typeof input.command === 'string' ? input.command : ''
268          if (!GIT_COMMAND_RE.test(command)) continue
269          if (block.id && CREATING_COMMAND_RE.test(command)) creatingToolIds.add(block.id)
270          for (const name of branchesFromCommand(command)) addBranch(name, 'output')
271          addUrls(command, 'output', GIT_KINDS)
272          continue
273        }
274        addUrls(JSON.stringify(input), 'output')
275      } else if (block.type === 'tool_result' && creatingToolIds.has(block.tool_use_id ?? '')) {
276        addUrls(resultText(block.content), 'output', GIT_KINDS)
277      }
278    }
279  }
280
281  if (parsed.artifacts.length > 0) {
282    for (const name of checkedOut) addBranch(name, 'output')
283  }
284  return parsed
285}
286
287const pad2 = (n: number) => String(n).padStart(2, '0')
288
289// 10/15 01:02
290export const shortTime = (ms: number) => {
291  const d = new Date(ms)
292  return `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
293}
294
295export const clockTime = (ms: number) => {
296  const d = new Date(ms)
297  return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
298}
299
300// 같은 날이면 끝 시각만, 다르면 둘 다.
301export const spanOf = (startedAt?: number, endedAt?: number) => {
302  if (startedAt === undefined) return ''
303  const start = shortTime(startedAt)
304  if (endedAt === undefined || endedAt - startedAt < 60_000) return start
305  const end = shortTime(endedAt)
306  return start.slice(0, 5) === end.slice(0, 5) ? `${start}–${end.slice(6)}` : `${start} – ${end}`
307}
308
309// Claude Code 는 프로젝트별 폴더 이름을 경로의 영숫자 아닌 글자를 '-' 로 바꿔 만든다.
310export const slugOf = (path: string) => path.replace(/[^a-zA-Z0-9]/g, '-')
311
312// git worktree list --porcelain 의 worktree 경로들
313export const parseWorktrees = (stdout: string): string[] =>
314  stdout.split('\n').filter(line => line.startsWith('worktree ')).map(line => line.slice('worktree '.length).trim())
315
316// displayPath 결과에서 파일 이름을 뺀 폴더 부분. worktree root 의 파일은 '.', 다른 worktree 의 root 는 'x:' 이다.
317export function dirOf(path: string): string {
318  const slash = path.lastIndexOf('/')
319  if (slash >= 0) return path.slice(0, slash) || '/'
320  const colon = path.indexOf(':')
321  return colon >= 0 ? path.slice(0, colon + 1) : '.'
322}
323
324// 경로를 가장 긴 worktree 기준 상대 경로로 줄인다. 다른 worktree 면 그 폴더 이름을 앞에 붙이되,
325// '<main 폴더>-' 로 시작하면 그 부분은 뺀다 (repo-feature → feature:).
326export function displayPath(path: string, roots: string[], home?: string): string {
327  const root = [...roots].sort((a, b) => b.length - a.length).find(one => path.startsWith(`${one}/`))
328  if (root) {
329    const relative = path.slice(root.length + 1)
330    if (root === roots[0]) return relative
331    const main = roots[0]?.split('/').pop() ?? ''
332    const name = root.split('/').pop() ?? root
333    return `${main && name.startsWith(`${main}-`) ? name.slice(main.length + 1) : name}:${relative}`
334  }
335  return home && path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
336}
337
types/index.d.ts 45 lines
1// file 은 Write/Edit 로 만든 파일, scratch 는 세션 scratchpad 의 파일.
2export type ArtifactKind =
3  | 'pr' | 'branch' | 'jira' | 'notion' | 'confluence' | 'slack' | 'github' | 'web' | 'file' | 'scratch'
4
5// 처음 수집된 곳. input 은 사용자 메시지의 글, output 은 그 밖의 전부(assistant 의 글, 도구 입력,
6// git·gh 명령의 입출력, checkout 된 branch, scratchpad 파일). pane 은 이 값으로 그룹을 나눈다.
7export type ArtifactSource = 'input' | 'output'
8
9export type Artifact = {
10  kind: ArtifactKind
11  // 행의 name 칸. file·scratch 는 파일 이름(basename)이다.
12  label: string
13  href?: string
14  // file·scratch 의 절대 경로. p 로 복사하는 값이다.
15  path?: string
16  // file·scratch 의 worktree 기준 상대 경로 등. 행의 path 칸에는 이 경로의 폴더 부분이 보인다.
17  displayPath?: string
18  source: ArtifactSource
19  // file 이 지금은 지워졌으면 true. 링크는 확인하지 않는다.
20  isGone?: boolean
21}
22
23export type SessionArtifacts = {
24  id: string
25  title: string
26  // 세션이 작업한 worktree 경로(마지막 cwd)
27  cwd?: string
28  startedAt?: number
29  endedAt?: number
30  artifacts: Artifact[]
31}
32
33// 현재 세션의 산출물. 아직 읽지 않았으면 loadedAt 이 없다.
34export type ArtifactIndex = {
35  session?: SessionArtifacts
36  loadedAt?: number
37  error?: string
38}
39
40declare module 'claude-code' {
41  interface PluginState {
42    'session-outputs': { index: ArtifactIndex; selected: number }
43  }
44}
45