A pane listing the folders that tools, scripts and agents wrote into this session, with what changed in each.

A mod for Claude Code. /outputs opens a pane that lists every folder the session's tools, scripts and agents wrote into, so each can be checked for changes.
Each row shows the folder's kind (temp, cache, public, config, project, other), its path, how many writes went there, which tool wrote last and when, how many files it holds and its newest change. A ● marks a folder that changed since it was marked seen. Artifact links published in the session are listed under the folders.
A click on a path or a link opens it (the folder in the file manager, the link in the browser), and so does its number key, 1 to 9 from the top. The mod asks the system to open it (open, or xdg-open), so this does not depend on the terminal passing hyperlinks on.
Other keys while the pane has focus: r refresh, s mark all seen, c clear the list, Esc close.
Write, Edit, NotebookEdit: the file's folder.Bash: what the command's text says it writes: redirects (>, >>), mkdir, touch, tee, the target of cp, mv, rsync, ln, and output flags (-o, --output, --out-dir, ...). Simple NAME=value variables set in the same command are expanded.Agent: the folder of a background agent's output file.Artifact: the published file's folder, and the artifact's link.python build.py shows nothing unless the command line names the output.claude --plugin-dir . # run the mod from this folder
claude plugin validate . # check the manifest and the hooks module
claude plugin test . # run tests/hooks/register.tsx 253 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FolderCheck, FolderKind, OutputFolder } from '../types'
5import { classify, dirname, outputsOf, resolvePath, shorten } from './paths'
6import type { Found } from './paths'
7
8const PANE = 'output-folders'
9const MAX_FOLDERS = 200
10const MAX_LINKS = 50
11
12const folders = atom({ plugin: 'output-folders', key: 'folders' } as const, [])
13const links = atom({ plugin: 'output-folders', key: 'links' } as const, [])
14const checks = atom({ plugin: 'output-folders', key: 'checks' } as const, [])
15const checkedAt = atom({ plugin: 'output-folders', key: 'checkedAt' } as const, 0)
16
17const KIND_COLOR: Record<FolderKind, string> = {
18 temp: 'warning',
19 cache: 'suggestion',
20 public: 'success',
21 config: 'remember',
22 project: 'text',
23 other: 'inactive',
24}
25
26const pad = (n: number) => String(n).padStart(2, '0')
27
28const clock = (ms: number) => {
29 const d = new Date(ms)
30
31 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
32}
33
34// Notes the folders and links one finished tool call wrote to.
35async function record($: EngineInterface, found: Found, tool: string) {
36 if (found.files.length + found.dirs.length + found.links.length === 0) {
37 return
38 }
39
40 const cwd = await $.session.cwd()
41 const home = (await $.env.get('HOME')) ?? ''
42 const at = await $.clock.now()
43 const placed = (path: string) => {
44 const resolved = resolvePath(path, cwd, home)
45
46 return resolved === undefined ? [] : [resolved]
47 }
48 const paths = [...found.files.flatMap(placed).map(dirname), ...found.dirs.flatMap(placed)]
49
50 if (paths.length > 0) {
51 await update($, folders, list => {
52 const byPath = new Map(list.map(folder => [folder.path, folder]))
53
54 for (const path of paths) {
55 const known = byPath.get(path)
56 byPath.set(path, {
57 path,
58 kind: classify(path, cwd, home),
59 writes: (known?.writes ?? 0) + 1,
60 lastAt: at,
61 lastBy: tool,
62 seenMtimeMs: known?.seenMtimeMs ?? 0,
63 })
64 }
65
66 return [...byPath.values()].sort((a, b) => b.lastAt - a.lastAt).slice(0, MAX_FOLDERS)
67 })
68 }
69
70 if (found.links.length > 0) {
71 await update($, links, list =>
72 [
73 ...found.links.map(url => ({ url, at })),
74 ...list.filter(link => !found.links.includes(link.url)),
75 ].slice(0, MAX_LINKS),
76 )
77 }
78}
79
80// Looks into each folder: its newest change and how many files it holds.
81async function check($: EngineInterface) {
82 const looked: FolderCheck[] = []
83
84 for (const folder of await read($, folders)) {
85 try {
86 const own = await $.fs.stat(folder.path)
87 const entries = await $.fs.list(folder.path)
88 const files = entries.filter(entry => entry.kind === 'file')
89
90 looked.push({
91 path: folder.path,
92 newestMs: Math.max(own.mtimeMs, ...files.map(file => file.mtimeMs)),
93 files: files.length,
94 isMissing: false,
95 })
96 } catch {
97 looked.push({ path: folder.path, newestMs: 0, files: 0, isMissing: true })
98 }
99 }
100
101 const at = await $.clock.now()
102 await update($, checks, () => looked)
103 await update($, checkedAt, () => at)
104}
105
106// Opens a folder or a link with the system's opener, since a terminal does
107// not always pass a hyperlink's click on.
108async function openTarget($: EngineInterface, target: string) {
109 try {
110 const ran = await $.process
111 .run(['open', target])
112 .catch(() => $.process.run(['xdg-open', target]))
113
114 if (ran.exitCode !== 0) {
115 $.ui.toast(`Could not open ${target}: ${ran.stderr.trim().split('\n')[0] ?? ''}`)
116 }
117 } catch {
118 $.ui.toast(`Could not open ${target}`)
119 }
120}
121
122// Keeps a long path's start and end.
123const fit = (text: string, room: number) =>
124 text.length <= room
125 ? text
126 : `${text.slice(0, Math.ceil((room - 1) / 2))}…${text.slice(text.length - Math.floor((room - 1) / 2))}`
127
128// Marks every folder's newest change as seen.
129async function markSeen($: EngineInterface) {
130 const looked = new Map((await read($, checks)).map(one => [one.path, one.newestMs]))
131
132 await update($, folders, list =>
133 list.map(folder => ({ ...folder, seenMtimeMs: looked.get(folder.path) ?? folder.seenMtimeMs })),
134 )
135}
136
137export const register: Register = on => {
138 on('session.start', async ($, e, next) => {
139 await $.command.register({
140 name: 'outputs',
141 description: 'Show the folders that tools, scripts and agents wrote into this session',
142 })
143
144 return next(e)
145 })
146
147 on('command.run', { command: 'outputs' }, async $ => {
148 await check($)
149 await $.ui.open({ id: PANE, title: 'Output folders', focus: true, closeOnEscape: true })
150
151 return { text: `Output folders: ${(await read($, folders)).length} tracked.` }
152 })
153
154 on('tool.call', async ($, e, next) => {
155 const ran = await next(e)
156
157 try {
158 if (ran.deny === undefined && ran.isError !== true) {
159 await record($, outputsOf(e.tool, e, ran.result), e.tool)
160 }
161 } catch {
162 // The call's answer stands whether or not its folders were noted.
163 }
164
165 return ran
166 })
167
168 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
169 const { Box, Button, Text } = $.ui.resolve(e)
170 const room = Math.max(20, e.props.bodyColumns - 16)
171 const list = await read($, folders)
172 const looked = new Map((await read($, checks)).map(one => [one.path, one]))
173 const at = await read($, checkedAt)
174 const urls = await read($, links)
175 const home = (await $.env.get('HOME')) ?? ''
176 const changed = list.filter(f => (looked.get(f.path)?.newestMs ?? 0) > f.seenMtimeMs).length
177
178 return (
179 <Box flexDirection="column">
180 <Box>
181 <Button key="refresh" hotkey="r" label="refresh" onPress={() => check($)} />
182 <Text> </Text>
183 <Button key="seen" hotkey="s" label="mark all seen" onPress={() => markSeen($)} />
184 <Text> </Text>
185 <Button
186 key="clear"
187 hotkey="c"
188 label="clear list"
189 onPress={async () => {
190 await update($, folders, () => [])
191 await update($, links, () => [])
192 await update($, checks, () => [])
193 }}
194 />
195 </Box>
196 <Text dimColor>
197 {list.length} folders, {changed} changed since seen
198 {at === 0 ? '' : `, checked ${clock(at)}`}
199 </Text>
200 {list.length === 0 ? <Text dimColor>Nothing has been written yet.</Text> : ''}
201 {list.length + urls.length === 0 ? (
202 ''
203 ) : (
204 <Text dimColor>Click a path or a link, or press its number, to open it.</Text>
205 )}
206 {list.map((folder, i) => {
207 const now = looked.get(folder.path)
208 const isChanged = (now?.newestMs ?? 0) > folder.seenMtimeMs
209
210 return (
211 <Box flexDirection="column">
212 <Box>
213 <Text color="warning">{isChanged ? '● ' : ' '}</Text>
214 <Text color={KIND_COLOR[folder.kind]}>{folder.kind.padEnd(8)}</Text>
215 <Button
216 key={`open-${i}`}
217 plain
218 hotkey={i < 9 ? String(i + 1) : undefined}
219 label={fit(shorten(folder.path, home), room)}
220 onPress={() => openTarget($, folder.path)}
221 />
222 </Box>
223 <Text dimColor wrap="truncate-end">
224 {' '}
225 {folder.writes} {folder.writes === 1 ? 'write' : 'writes'}, last by {folder.lastBy} at{' '}
226 {clock(folder.lastAt)}
227 {now === undefined
228 ? ''
229 : now.isMissing
230 ? ', folder is gone'
231 : `, ${now.files} files, newest change ${clock(now.newestMs)}`}
232 </Text>
233 </Box>
234 )
235 })}
236 {urls.length === 0 ? '' : <Text bold>Artifact links</Text>}
237 {urls.map((link, i) => (
238 <Box>
239 <Text dimColor>{clock(link.at)} </Text>
240 <Button
241 key={`link-${i}`}
242 plain
243 hotkey={list.length + i < 9 ? String(list.length + i + 1) : undefined}
244 label={fit(link.url, room)}
245 onPress={() => openTarget($, link.url)}
246 />
247 </Box>
248 ))}
249 </Box>
250 )
251 })
252}
253hooks/paths.ts 176 lines1import type { FolderKind } from '../types'
2
3export type Found = { files: string[]; dirs: string[]; links: string[] }
4
5const isString = (value: unknown): value is string => typeof value === 'string' && value !== ''
6
7export const dirname = (path: string) => {
8 const trimmed = path.length > 1 ? path.replace(/\/+$/, '') : path
9 const cut = trimmed.lastIndexOf('/')
10
11 return cut <= 0 ? '/' : trimmed.slice(0, cut)
12}
13
14// Folds `.` and `..` and makes the path absolute; undefined for a path that
15// cannot be placed (an unexpanded variable, a glob, a device).
16export const resolvePath = (path: string, cwd: string, home: string) => {
17 if (/[$`*?{}]/.test(path) || path === '' || path === '-') {
18 return undefined
19 }
20
21 const expanded = path === '~' ? home : path.startsWith('~/') ? `${home}${path.slice(1)}` : path
22 const absolute = expanded.startsWith('/') ? expanded : `${cwd}/${expanded}`
23 const parts: string[] = []
24
25 for (const part of absolute.split('/')) {
26 if (part === '..') {
27 parts.pop()
28 } else if (part !== '' && part !== '.') {
29 parts.push(part)
30 }
31 }
32
33 const folded = `/${parts.join('/')}`
34
35 return folded.startsWith('/dev/') || folded === '/dev' ? undefined : folded
36}
37
38export const classify = (path: string, cwd: string, home: string): FolderKind => {
39 const p = `${path}/`
40
41 if (/^\/(private\/)?(tmp|var\/folders)\//.test(p) || /\/(tmp|temp|scratchpad)\//i.test(p)) {
42 return 'temp'
43 }
44
45 if (/\/(\.cache|cache|caches|\.next|\.turbo|\.parcel-cache|\.vite)\//i.test(p)) {
46 return 'cache'
47 }
48
49 if (/\/(public|dist|build|out|static|site|_site)\//.test(p)) {
50 return 'public'
51 }
52
53 if (p.startsWith(`${home}/.`) || p.startsWith(`${home}/Library/`)) {
54 return 'config'
55 }
56
57 return p.startsWith(`${cwd}/`) ? 'project' : 'other'
58}
59
60// A path names a file when its last part has an extension.
61export const isFileLike = (path: string) =>
62 !path.endsWith('/') && /\.[A-Za-z0-9]+$/.test(path.slice(path.lastIndexOf('/') + 1))
63
64const tokenize = (line: string) => {
65 const tokens: string[] = []
66 const pattern = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(&&|\|\||[;|]|\d*>>?&?\d*|[^\s"';|&<>]+)/g
67
68 for (const match of line.matchAll(pattern)) {
69 tokens.push(match[1] ?? match[2] ?? match[3] ?? '')
70 }
71
72 return tokens
73}
74
75const OUTPUT_FLAGS = new Set(['-o', '--output', '--out', '--out-dir', '--outdir', '--output-dir'])
76const COPYING = new Set(['cp', 'mv', 'rsync', 'ln'])
77const CREATING = new Set(['touch', 'tee'])
78
79// The files and folders a shell command writes, as far as its text says: its
80// redirects, `mkdir`, `touch`, `tee`, the target of a copy, an output flag.
81export const shellOutputs = (command: string): Pick<Found, 'files' | 'dirs'> => {
82 const files: string[] = []
83 const dirs: string[] = []
84 const vars = new Map<string, string>()
85 const text = command.replace(/<<-?\s*['"]?(\w+)['"]?[\s\S]*?\n\1\b/g, ' ')
86 const expand = (token: string) =>
87 token.replace(/\$\{?([A-Za-z_]\w*)\}?/g, (whole, name: string) => vars.get(name) ?? whole)
88 const add = (token: string | undefined, isDir?: boolean) => {
89 if (token === undefined || token.startsWith('-')) {
90 return
91 }
92
93 const path = expand(token)
94 ;((isDir ?? !isFileLike(path)) ? dirs : files).push(path)
95 }
96
97 let words: string[] = []
98 const flush = () => {
99 const name = words[0]?.slice(words[0].lastIndexOf('/') + 1)
100 const args = words.slice(1).filter(word => !word.startsWith('-'))
101
102 if (name === 'mkdir') {
103 args.forEach(arg => add(arg, true))
104 } else if (name !== undefined && CREATING.has(name)) {
105 args.forEach(arg => add(arg, false))
106 } else if (name !== undefined && COPYING.has(name) && args.length > 1) {
107 add(args.at(-1))
108 }
109
110 words.forEach((word, i) => {
111 if (OUTPUT_FLAGS.has(word)) {
112 add(words[i + 1])
113 } else if (/^--(output|out|out-dir|outdir|output-dir)=/.test(word)) {
114 add(word.slice(word.indexOf('=') + 1))
115 }
116 })
117 words = []
118 }
119
120 const tokens = tokenize(text)
121
122 tokens.forEach((token, i) => {
123 const assignment = /^([A-Za-z_]\w*)=(.+)$/.exec(token)
124
125 if (['&&', '||', ';', '|'].includes(token)) {
126 flush()
127 } else if (/^\d*>>?$/.test(token)) {
128 add(tokens[i + 1], false)
129 } else if (assignment !== null && words.length === 0) {
130 vars.set(assignment[1] ?? '', expand(assignment[2] ?? ''))
131 } else if (!/^\d*>/.test(token) && !/^\d*>>?$/.test(tokens[i - 1] ?? '')) {
132 words.push(token)
133 }
134 })
135 flush()
136
137 return { files, dirs }
138}
139
140const ARTIFACT_LINK = /https:\/\/claude\.ai\/[^\s"'\\)]*artifact[^\s"'\\)]*/g
141
142// What a finished tool call wrote, read from its input and its result.
143export const outputsOf = (tool: string, input: Readonly<Record<string, unknown>>, result: unknown): Found => {
144 const found: Found = { files: [], dirs: [], links: [] }
145 const answer = (typeof result === 'object' && result !== null ? result : {}) as Readonly<
146 Record<string, unknown>
147 >
148
149 if (tool === 'Write' || tool === 'Edit') {
150 found.files.push(...[input.file_path].filter(isString))
151 } else if (tool === 'NotebookEdit') {
152 found.files.push(...[input.notebook_path].filter(isString))
153 } else if (tool === 'Bash') {
154 const shell = shellOutputs(isString(input.command) ? input.command : '')
155 found.files.push(...shell.files, ...[answer.rawOutputPath].filter(isString))
156 found.dirs.push(...shell.dirs)
157 } else if (tool === 'Agent') {
158 found.files.push(...[answer.outputFile].filter(isString))
159 } else if (tool === 'Artifact') {
160 const isPublish = input.action === undefined || input.action === 'publish'
161 const paths = Array.isArray(input.file_paths) ? input.file_paths : []
162
163 if (isPublish) {
164 found.files.push(...[input.file_path, ...paths].filter(isString))
165 }
166
167 found.dirs.push(...[input.out_dir].filter(isString))
168 found.links.push(...(JSON.stringify(result ?? '').match(ARTIFACT_LINK) ?? []))
169 }
170
171 return found
172}
173
174export const shorten = (path: string, home: string) =>
175 path === home ? '~' : path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path
176types/index.d.ts 29 lines1export type FolderKind = 'temp' | 'cache' | 'public' | 'config' | 'project' | 'other'
2
3// A folder something wrote into. `seenMtimeMs` is the newest change the
4// person has marked as seen.
5export type OutputFolder = {
6 path: string
7 kind: FolderKind
8 writes: number
9 lastAt: number
10 lastBy: string
11 seenMtimeMs: number
12}
13
14export type OutputLink = { url: string; at: number }
15
16// What a folder held when it was last looked at.
17export type FolderCheck = { path: string; newestMs: number; files: number; isMissing: boolean }
18
19declare module 'claude-code' {
20 interface PluginState {
21 'output-folders': {
22 folders: OutputFolder[]
23 links: OutputLink[]
24 checks: FolderCheck[]
25 checkedAt: number
26 }
27 }
28}
29