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

Personal Claude Code mods.
| Mod | Command | Description |
|---|---|---|
git-graph | /git-graph | Toggles 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 | /outputs | Toggles 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-todo | Toggles 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.jsonlThe 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.
auto ● (on) or auto ○ (off).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.프롬프트(붙여넣은 텍스트 포함)와 @경로로 참조한 파일을 전송 전에 검사한다.
| 처리 | 유형 |
|---|---|
| 확인 없이 차단 | 주민등록번호·외국인등록번호(구분자 있는 형식), 여권번호·운전면허번호(키워드 또는 구분자 형식) |
| "그래도 보낼까요?" 확인 | 구분자 없는 13자리 주민번호 형식, 휴대전화, 이메일, 카드(Luhn), 계좌(키워드), 주소(시·도~번지), 이미지·문서 첨부 |
-p, SDK)나 검사 중 오류가 나면 보내지 않는다(fail-closed).Herdr pane 안에서 "이 화면 캡처해줘" 처럼 요청하면 스킬이 scripts/cap.sh 를 실행한다.
cap.sh [--pane <id>] [--out <png|dir>] [--title <text>] [--lines N]
$HERDR_PANE_ID)의 보이는 화면. --lines N 이면 scrollback 포함D2KodingLigature Nerd Font (한글 2칸 정렬용)/outputs 로 pane 을 열고 닫는다. 현재 진행 중인 세션의 transcript(~/.claude/projects/<slug>/<session id>.jsonl)와 scratchpad 를 읽어 수집 출처별로 두 그룹에 나눠 보여준다. 다른 세션의 산출물은 보이지 않는다.
| 그룹 | 출처 |
|---|---|
| output | assistant 의 글과 도구 입력(MCP 호출 포함)에 나온 URL, Write·Edit·NotebookEdit 로 만든 파일, git·gh 명령의 입출력, 세션 중 checkout 된 branch, 세션 scratchpad 의 파일 |
| input | 사용자 메시지의 글에 나온 URL |
.)이고, 링크의 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 링크는 참고 자료라 제외한다git checkout -b·git switch -c·git worktree add -b·git push -u 명령, git push 출력의 pull/new/<branch> URL 에서 찾는다/tmp 아래라 재부팅 후에는 사라진다r 로 다시 읽는다. 열려 있으면 10초마다 갱신한다j/k 로 한 item 씩 이동하고, c 는 선택한 item 의 이름, p 는 경로(파일은 절대 경로, 링크는 URL, URL 없는 branch 는 이름)를 클립보드에 복사한다tail -c 로 나눠 읽는다 ($.fs.read 상한)file:// URL 이 덧붙지 않도록 링크를 걸지 않는다. p 로 절대 경로를 복사한다claude.ai/code/session_…, claude.com/claude-code), api.* 엔드포인트, Read·WebFetch 등 도구 결과 속 URL 은 잡음이라 제외한다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
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
claude --plugin-dir ./git-graph # loads the working copy, reloads on save
claude plugin validate .
claude plugin test ./git-graphhooks/register.tsx 363 lines1import { 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}
363hooks/parse.ts 337 lines1import 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}
337types/index.d.ts 45 lines1// 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