SLOPSHOPPER

output-folders

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

newpaneguardcommandtoastprocess
v0.2.0no licenseupdated 2026-10-06laszloprekop/claude-code-output-folders
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · output-folders
│ ┃ Output folders ✕ › fix the failing auth test and add an audit log call │ ┃ [ refresh ] [ mark all seen ] [ clear list ] │ ┃ 1 folders, 1 changed since seen, checked ⏺ Read(src/auth.ts) │ ┃ 08:53:20 ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Click a path or a link, or press its number, ⎿ Added 2 lines, removed 1 line │ ┃ to open it. ⏺ Bash(bun test) │ ┃ ● project 1: /work/app/src ⎿ 3 pass, 1 fail │ ┃ 3 writes, last by Write at 08:… │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /outputs │ ⎿ output-folders: Output folders: 1 tracked. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Output folders
[ refresh ] [ mark all seen ] [ clear list ] 1 folders, 1 changed since seen, checked 08:53:20 Click a path or a link, or press its number, to open it. ● project 1: /work/app/src 3 writes, last by Write at 08:53:20, 0 files, …
README

output-folders

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.

What counts as a write

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

Limits

  • A script's own writes are invisible: python build.py shows nothing unless the command line names the output.
  • Paths with unexpanded variables, globs or command substitutions are skipped.
  • A folder is checked one level deep.
  • The mod API is early access and may change between Claude Code releases. Built against 2.1.292.

Develop

claude --plugin-dir .        # run the mod from this folder
claude plugin validate .     # check the manifest and the hooks module
claude plugin test .         # run tests/
Source 3 files
hooks/register.tsx 253 lines
1import { 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}
253
hooks/paths.ts 176 lines
1import 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
176
types/index.d.ts 29 lines
1export 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