SLOPSHOPPER

open-tray

A pane of things to look at or listen to: what Claude made this session, things related to what you are working on, and local servers. Project rules in…

newpaneguardcommandtoastprompt
v0.5.0MITupdated 2026-10-05jakerains/claudemods/open-tray
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · open-tray
│ ┃ Open Tray ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ Open Tray │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing here yet. ⎿ Read 6 lines │ ┃ Films, audio, images and pages I make for y ⏺ Update(src/auth.ts) │ ┃ here. As we work, things related to what we ⎿ Added 2 lines, removed 1 line │ ┃ show up below. ⏺ 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 │ │ › /tray │ ⎿ open-tray: Open Tray: 0 made this session, src related. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Open Tray
◆ Open Tray src Nothing here yet. Films, audio, images and pages I make for you land here. As we work, things related to what we are on show up below.
README

Open Tray

A Claude Code mod: a pane (/tray) of things to look at or listen to while you work, so you never have to ask Claude to "open that in Finder" again.

  • Made this session: whatever Claude opens for you, renders, or hands you with its offer tool. Stays until you remove it.
  • Related to what you're on: found for the area you're working in and swapped out when you move on. With no setup, the area is the folder Claude is working in, and you get the newest films, audio, images, pages and PDFs there (skipping node_modules, builds, .git and archives).
  • Local servers: dev servers Claude starts (pnpm dev, npm run dev, vite, next dev, python -m http.server, …) with a live/stopped dot.

Keys in the pane: o/Enter open · f show in Finder · a play a film's audio only (needs ffmpeg) · p keep a related item · x remove · j/k move · r restart a stopped server. macOS (open).

Teaching it your project

Rules tell the tray how to name an area ("Chapter 3") from your prompts and file paths, and where that area's good stuff lives. Put them in either:

  • <repo>/.claude/tray.json (travels with the repo), or
  • ~/.claude/open-tray/rules/<repo folder name>.json (yours alone).

Start from examples/tray.json; its $comment explains every field.

Rules decide which searches run and which patterns run on your prompts, so a repo's .claude/tray.json is not used until you trust it: the tray tells you it's there, you read it, then /tray trust (/tray untrust to stop). Trust is for that exact content; if the file changes, the tray asks again. Your own ~/.claude/open-tray/rules/ file is always used, and wins over the repo's.

open resolves links first and opens only films, audio, images, pages, documents and folders; anything else (apps, scripts, bundles, profiles) is shown in Finder instead of launched.

Source 3 files
hooks/register.tsx 720 lines
1// Open Tray: a pane of things to look at or listen to, in two sections.
2//
3// - Made this session: what Claude opened, rendered or handed over, plus
4//   anything the person kept. These stay until removed.
5// - Related to what we're on: found for the area being worked on (a module, a
6//   folder) and swapped out when the area changes. A project's rules file says
7//   how to spot its areas and where their good stuff lives (rules.ts); without
8//   one, the area is the folder Claude works in.
9//
10// Nothing here is project-specific: projects bring their own rules (a repo's
11// `.claude/tray.json`, or `~/.claude/open-tray/rules/<repo>.json`).
12
13import { atom, read, update } from 'claude-code'
14import type { EngineInterface, Register, RenderChildren } from 'claude-code'
15
16import type { Related, RelatedGroup, ReviewPlayer, TrayAction, TrayItem } from '../types'
17import {
18  actionPattern,
19  actionsFor,
20  areaIn,
21  baseName,
22  dirsScript,
23  findScript,
24  folderArea,
25  groupItems,
26  kindOf,
27  relatedPlan,
28  rulesPaths,
29  skipList,
30  startsServer,
31  tagOf,
32} from './rules'
33import type { Area, Rules } from './rules'
34
35const PANE = 'open-tray'
36const MAX_ITEMS = 50
37const FRESH_MS = 15 * 60_000
38const CHECK_MS = 20_000
39// The area changes after this many of the last WINDOW votes point at it.
40const SWITCH_VOTES = 3
41const WINDOW = 8
42const REFRESH_GAP_MS = 60_000
43
44const NO_RELATED: Related = { area: null, label: '', groups: [], actions: [], suggested: [] }
45
46// Held by the host, so the tray survives a hot reload of this file.
47const items = atom({ plugin: 'open-tray', key: 'items' } as const, [])
48const selected = atom({ plugin: 'open-tray', key: 'selected' } as const, 0)
49const players = atom({ plugin: 'open-tray', key: 'players' } as const, [])
50const related = atom({ plugin: 'open-tray', key: 'related' } as const, NO_RELATED)
51const signals = atom({ plugin: 'open-tray', key: 'signals' } as const, [])
52const hidden = atom({ plugin: 'open-tray', key: 'hidden' } as const, [])
53
54const ICONS: Record<TrayItem['kind'], string> = {
55  video: '▶', audio: '♪', image: '▣', page: '◧', doc: '≡', folder: '▤', url: '↗', other: '·',
56}
57// One small palette: a violet accent, soft greys, and a colour per kind.
58const THEME = {
59  accent: '#a78bfa',
60  onAccent: '#14121c',
61  picked: '#26243a',
62  sub: '#b8b5c8',
63  faint: '#6e6a80',
64  rule: '#3a3750',
65  go: '#4ade80',
66  stop: '#f87171',
67}
68const KIND_COLORS: Record<TrayItem['kind'], string> = {
69  video: '#f472b6',
70  audio: '#22d3ee',
71  image: '#fbbf24',
72  page: '#60a5fa',
73  doc: '#cbd5e1',
74  folder: '#34d399',
75  url: '#38bdf8',
76  other: '#94a3b8',
77}
78const MEDIA_PATH = /(?:~|\/)[^\s'"`<>|;,()\]]+\.(?:mp4|mov|webm|m4v|mp3|wav|m4a|aac|flac|ogg|png|jpe?g|gif|webp|pdf)\b/gi
79const LOCAL_URL = /https?:\/\/(?:127\.0\.0\.1|localhost|[\w.-]+\.localhost)(?::\d+)?(?:\/[^\s'"`)\]<>]*)?/g
80const OUTPUT_FILE = /\/[^\s'"`]+\/tasks\/[\w-]+\.output\b/
81
82// This load's view of the project: its root and rules, read at session start.
83let root = ''
84let rules: Rules | null = null
85// A repo's own rules file waiting for /tray trust: its hash and where it is.
86let untrusted: { path: string; hash: string } | null = null
87let lastRefreshAt = 0
88
89export const register: Register = on => {
90  on('session.start', async ($, e, next) => {
91    const result = await next(e)
92    root = (await $.session.repo())?.root ?? (await $.session.root())
93    rules = await loadRules($, root)
94    await $.command.register({
95      name: 'tray',
96      description: "Show the Open Tray: things made for you this session, and things related to what we are working on (trust: use this repo's .claude/tray.json)",
97      argumentHint: '[trust|untrust]',
98      immediate: true,
99    })
100    await $.tool.register({
101      name: 'offer',
102      description:
103        "Put a file, folder or local URL in the user's Open Tray pane, where they open it, reveal it in Finder or play a film's audio alone. " +
104        'Use it for anything you produced for the user to look at, listen to or read (renders, audio takes, screenshots, review sheets, ' +
105        'scripts, local review URLs), and with suggest: true for existing things you think they will want for the current task. ' +
106        'Paths must be absolute.',
107      inputSchema: {
108        type: 'object',
109        properties: {
110          target: { type: 'string', description: 'Absolute path, or an http(s) URL' },
111          label: { type: 'string', description: 'Short name to show; defaults to the file name' },
112          suggest: {
113            type: 'boolean',
114            description: 'true: list it under "Related to what we are on" (it goes when the work moves on). false or absent: "Made this session" (it stays).',
115          },
116        },
117        required: ['target'],
118      },
119    })
120    $.clock.every(CHECK_MS, () => void checkPlayers($))
121
122    return result
123  })
124
125  // What the person asks about votes for an area, and a clear mention switches at once.
126  on('prompt.submit', async ($, e, next) => {
127    const result = await next(e)
128    if (e.origin.kind === 'composer') {
129      const area = areaIn(e.text, rules)
130      if (area) await vote($, area, true)
131    }
132
133    return result
134  })
135
136  on('tool.call', { tool: 'mcp__open-tray__offer' }, async ($, e) => {
137    const input = e as unknown as { target?: unknown; label?: unknown; suggest?: unknown }
138    const target = typeof input.target === 'string' ? input.target.trim() : ''
139    if (!target) {
140      return { result: 'target is required', isError: true }
141    }
142    const label = typeof input.label === 'string' ? input.label : undefined
143    const item = await itemFor($, target, label)
144    if (!item) {
145      return { result: `Not found: ${target}` }
146    }
147    if (input.suggest === true) {
148      await update($, related, r => ({ ...r, suggested: [item, ...r.suggested.filter(s => s.target !== item.target)].slice(0, 8) }))
149      return { result: `Suggested in the Open Tray: ${item.label}` }
150    }
151    await addItem($, item, true)
152
153    return { result: `In the Open Tray: ${item.label}` }
154  })
155
156  on('tool.call', async ($, e, next) => {
157    const result = await next(e)
158    if ('deny' in result && result.deny) {
159      return result
160    }
161    const input = e as unknown as Record<string, unknown>
162
163    // The files Claude works on vote for the area.
164    if (!e.agentId) {
165      const path = String(input.file_path ?? input.path ?? input.notebook_path ?? '')
166      if (path.startsWith('/')) {
167        const area = areaIn(path, rules) ?? (rules?.areas?.length ? null : folderArea(path, root))
168        if (area) await vote($, area, false)
169      }
170    }
171
172    if (e.tool === 'Bash') {
173      const command = String(input.command ?? '')
174      const output = typeof result.text === 'string' ? result.text : ''
175      const cwd = await $.session.cwd()
176      for (const target of openTargets(command, cwd)) {
177        const item = await itemFor($, target)
178        if (item) await addItem($, item, false)
179      }
180      const now = await $.clock.now()
181      for (const path of new Set(output.match(MEDIA_PATH) ?? [])) {
182        const full = await expandHome($, path)
183        try {
184          const stat = await $.fs.stat(full)
185          if (stat.kind === 'file' && now - stat.mtimeMs < FRESH_MS) {
186            const item = await itemFor($, full)
187            if (item) await addItem($, item, false)
188          }
189        } catch {
190          // Printed, but not on disk.
191        }
192      }
193      if (startsServer(command, rules)) {
194        const tag = tagOf(command, rules)
195        await notePlayers($, output, tag)
196        const outFile = output.match(OUTPUT_FILE)?.[0]
197        if (outFile) {
198          for (const wait of [4000, 12000, 30000]) {
199            $.clock.after(wait, () => void readOutputFile($, outFile, tag))
200          }
201        }
202      }
203    }
204
205    return result
206  })
207
208  // New renders land during a turn; look again once it ends (at most once a minute).
209  on('turn.complete', async ($, e, next) => {
210    const result = await next(e)
211    const r = await read($, related)
212    const now = await $.clock.now()
213    if (!e.agentId && r.area && now - lastRefreshAt > REFRESH_GAP_MS) {
214      const area = areaFromKey(r.area, await read($, signals))
215      if (area) await refresh($, area)
216    }
217
218    return result
219  })
220
221  on('command.run', { command: 'tray' }, async ($, e) => {
222    const word = e.args.trim().toLowerCase()
223    if (word === 'trust' || word === 'untrust') return { text: await trustRepo($, word === 'trust') }
224    const opened = await $.ui.open({ id: PANE, title: 'Open Tray', focus: true })
225    const count = (await read($, items)).length
226    const r = await read($, related)
227
228    const note = untrusted ? ` This repo has rules (${untrusted.path}); review them, then /tray trust to use them.` : ''
229    return {
230      text: opened.isPlaced
231        ? `Open Tray: ${count} made this session${r.area ? `, ${r.label} related` : ''}.${note}`
232        : 'Open Tray could not be placed; widen the terminal.',
233    }
234  })
235
236  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
237    const { Box, Text, Button } = $.ui.resolve(e)
238    const mine = await read($, items)
239    const r = await read($, related)
240    const away = new Set(await read($, hidden))
241    const live = await read($, players)
242    const now = await $.clock.now()
243    const columns = e.props.bodyColumns
244    const rows = flatten(mine, r, away)
245    const pick = Math.min(await read($, selected), Math.max(0, rows.length - 1))
246    const stopped = live.find(p => !p.isAlive)
247    const mineCount = rows.filter(row => row.place === 'mine').length
248    const areaCount = rows.length - mineCount
249
250    const title = (text: string, count: number) => (
251      <Box flexDirection="row" marginTop={1}>
252        <Text bold color={THEME.accent}>{text.toUpperCase()}</Text>
253        <Text color={THEME.faint}>{` ${count} `}</Text>
254        <Text color={THEME.rule}>{'─'.repeat(Math.max(0, columns - text.length - String(count).length - 2))}</Text>
255      </Box>
256    )
257
258    const line = (index: number) => {
259      const row = rows[index]!
260      const isPicked = index === pick
261      const bar = <Text color={THEME.accent}>{isPicked ? '▌' : ' '}</Text>
262      if (row.kind === 'action') {
263        return (
264          <Box flexDirection="row" backgroundColor={isPicked ? THEME.picked : undefined}>
265            {bar}
266            <Text color={THEME.go}>{' ⏵ '}</Text>
267            <Button key={`row-${index}`} plain label={clip(row.action.label, columns - 5)} onPress={() => void runRow($, index)} />
268          </Box>
269        )
270      }
271      const meta = `${row.item.tag ? `${row.item.tag}  ` : ''}${ago(now - row.item.addedAt)}`
272      return (
273        <Box flexDirection="row" justifyContent="space-between" width={columns} backgroundColor={isPicked ? THEME.picked : undefined}>
274          <Box flexDirection="row" flexShrink={1}>
275            {bar}
276            <Text color={KIND_COLORS[row.item.kind]}>{` ${ICONS[row.item.kind]} `}</Text>
277            <Button
278              key={`row-${index}`}
279              plain
280              label={clip(row.item.label, Math.max(8, columns - meta.length - 7))}
281              onPress={() => void runRow($, index)}
282            />
283          </Box>
284          <Text color={THEME.faint}>{` ${meta} `}</Text>
285        </Box>
286      )
287    }
288
289    // The area's rows, under a small heading per group.
290    const areaLines: RenderChildren[] = []
291    let lastGroup = ''
292    rows.forEach((row, index) => {
293      if (row.place !== 'area') return
294      if (row.group !== lastGroup) {
295        areaLines.push(<Text color={THEME.sub}>{`  ${row.group}`}</Text>)
296        lastGroup = row.group
297      }
298      areaLines.push(line(index))
299    })
300
301    return (
302      <Box flexDirection="column" width={columns}>
303        <Box flexDirection="row" justifyContent="space-between" width={columns}>
304          <Text bold>
305            <Text color={THEME.accent}>{'◆ '}</Text>
306            {'Open Tray'}
307          </Text>
308          {r.label ? (
309            <Text backgroundColor={THEME.accent} color={THEME.onAccent} bold>{` ${r.label} `}</Text>
310          ) : (
311            <Text color={THEME.faint}>{'following your work'}</Text>
312          )}
313        </Box>
314
315        {rows.length === 0 && (
316          <Box flexDirection="column" marginTop={1} paddingX={1}>
317            <Text color={THEME.sub}>{'Nothing here yet.'}</Text>
318            <Text color={THEME.faint} wrap="wrap">
319              {'Films, audio, images and pages I make for you land here. As we work, things related to what we are on show up below.'}
320            </Text>
321          </Box>
322        )}
323
324        {mineCount > 0 && title('Made this session', mineCount)}
325        {rows.map((row, index) => (row.place === 'mine' ? line(index) : null))}
326
327        {areaCount > 0 && title(r.label || 'Related', areaCount)}
328        {areaLines}
329
330        {rows.length > 0 && (
331          <Box flexDirection="row" flexWrap="wrap" marginTop={1}>
332            <Button key="open" plain hotkey="o" label="open   " onPress={() => void runRow($, pick)} />
333            <Button key="finder" plain hotkey="f" label="Finder   " onPress={() => void act($, pick, 'finder')} />
334            <Button key="audio" plain hotkey="a" label="audio   " onPress={() => void act($, pick, 'audio')} />
335            <Button key="keep" plain hotkey="p" label="keep   " onPress={() => void keep($, pick)} />
336            <Button key="remove" plain hotkey="x" label="remove   " onPress={() => void remove($, pick)} />
337            <Button key="down" plain hotkey="j" label="down   " onPress={() => void move($, 1)} />
338            <Button key="up" plain hotkey="k" label="up" onPress={() => void move($, -1)} />
339          </Box>
340        )}
341
342        {live.length > 0 && (
343          <Box flexDirection="column" borderStyle="round" borderColor={THEME.rule} paddingX={1} marginTop={1}>
344            <Text bold color={THEME.sub}>{'Local servers'}</Text>
345            {live.map((player, index) => (
346              <Box flexDirection="row">
347                <Text color={player.isAlive ? THEME.go : THEME.stop}>{player.isAlive ? '● ' : '○ '}</Text>
348                <Button
349                  key={`player-${index}`}
350                  plain
351                  dimColor={!player.isAlive}
352                  label={`${player.tag ?? 'player'}  ${player.url}`}
353                  onPress={() => void $.process.run(['open', player.url])}
354                />
355                {!player.isAlive && <Text color={THEME.stop}>{'  stopped'}</Text>}
356              </Box>
357            ))}
358            {stopped && (
359              <Button key="restart" plain hotkey="r" label={`restart ${stopped.tag ?? stopped.url}`} onPress={() => void restart($, stopped)} />
360            )}
361          </Box>
362        )}
363      </Box>
364    )
365  })
366}
367
368// ── The list ─────────────────────────────────────────────────────────────────
369
370type Row =
371  | { kind: 'item'; place: 'mine' | 'area'; group: string; item: TrayItem; mine: boolean }
372  | { kind: 'action'; place: 'area'; group: string; action: TrayAction }
373
374function flatten(mine: TrayItem[], r: Related, away: Set<string>): Row[] {
375  const rows: Row[] = mine.map(item => ({ kind: 'item', place: 'mine', group: '', item, mine: true }))
376  const taken = new Set(mine.map(m => m.target))
377  const fresh = (item: TrayItem) => !taken.has(item.target) && !away.has(item.target)
378  for (const item of r.suggested.filter(fresh)) rows.push({ kind: 'item', place: 'area', group: 'Suggested', item, mine: false })
379  for (const group of r.groups) {
380    for (const item of group.items.filter(fresh)) rows.push({ kind: 'item', place: 'area', group: group.label, item, mine: false })
381  }
382  for (const action of r.actions) rows.push({ kind: 'action', place: 'area', group: 'Start', action })
383
384  return rows
385}
386
387async function rowsNow($: EngineInterface): Promise<Row[]> {
388  return flatten(await read($, items), await read($, related), new Set(await read($, hidden)))
389}
390
391async function runRow($: EngineInterface, index: number) {
392  const row = (await rowsNow($))[index]
393  if (!row) return
394  await update($, selected, () => index)
395  if (row.kind === 'action') {
396    await $.prompt.submit({ text: row.action.prompt })
397    return
398  }
399  await openSafely($, row.item.target, row.item.kind)
400}
401
402// Opens a file or folder to look at it. The link is resolved first, and only
403// what is plainly for viewing opens (films, audio, images, pages, documents, a
404// folder); anything else (an app, a script, a bundle, a profile) is shown in
405// Finder, since `open` would launch it.
406async function openSafely($: EngineInterface, target: string, kind: string) {
407  if (kind === 'url') {
408    if (/^https?:\/\//i.test(target)) await $.process.run(['open', target])
409    return
410  }
411  const linked = await $.fs.stat(target, { resolve: true }).catch(() => null)
412  const real = linked?.realPath ?? target
413  const stat = await $.fs.stat(real).catch(() => null)
414  if (!stat) {
415    $.ui.toast(`Not found: ${baseName(target)}`)
416    return
417  }
418  const isViewable = stat.kind === 'file' ? kindOf(real) !== 'other' : stat.kind === 'dir' && !baseName(real).includes('.')
419  if (!isViewable) {
420    $.ui.toast('Shown in Finder: Open Tray opens only films, audio, images, pages and documents')
421    await $.process.run(['open', '-R', real])
422    return
423  }
424  await $.process.run(['open', real])
425}
426
427async function act($: EngineInterface, index: number, action: 'finder' | 'audio') {
428  const row = (await rowsNow($))[index]
429  if (!row || row.kind !== 'item') return
430  await update($, selected, () => index)
431  const item = row.item
432  if (action === 'finder') {
433    if (item.kind === 'url') {
434      $.ui.toast('A URL has no Finder location')
435      return
436    }
437    await $.process.run(['open', '-R', item.target])
438    return
439  }
440  if (item.kind === 'audio') {
441    await openSafely($, item.target, item.kind)
442    return
443  }
444  if (item.kind !== 'video') {
445    $.ui.toast('Audio only works on a film or an audio file')
446    return
447  }
448  const tmp = ((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/$/, '')
449  const out = `${tmp}/open-tray/${baseName(item.target).replace(/\.[^.]+$/, '')}.m4a`
450  await $.fs.write(`${tmp}/open-tray/.keep`, '')
451  $.ui.toast(`Pulling the audio out of ${item.label}…`)
452  const run = await $.process.run(['ffmpeg', '-y', '-loglevel', 'error', '-i', item.target, '-vn', '-c:a', 'aac', '-b:a', '192k', out], {
453    timeoutMs: 180_000,
454  })
455  if (run.exitCode !== 0) {
456    $.ui.toast(`ffmpeg could not pull the audio: ${run.stderr.trim().split('\n').pop() ?? ''}`)
457    return
458  }
459  await $.process.run(['open', out])
460}
461
462// Keep: a related item moves up to "Made this session", so it stays when the area changes.
463async function keep($: EngineInterface, index: number) {
464  const row = (await rowsNow($))[index]
465  if (!row || row.kind !== 'item' || row.mine) return
466  await addItem($, { ...row.item, addedAt: await $.clock.now() }, false)
467  $.ui.toast(`Kept: ${row.item.label}`)
468}
469
470async function remove($: EngineInterface, index: number) {
471  const row = (await rowsNow($))[index]
472  if (!row || row.kind !== 'item') return
473  if (row.mine) await update($, items, list => list.filter(i => i.target !== row.item.target))
474  else await update($, hidden, list => [...list, row.item.target].slice(-200))
475  await update($, selected, n => Math.max(0, n - (n >= index ? 1 : 0)))
476}
477
478async function move($: EngineInterface, by: number) {
479  const count = (await rowsNow($)).length
480  await update($, selected, n => Math.min(Math.max(0, n + by), Math.max(0, count - 1)))
481}
482
483// ── Items ────────────────────────────────────────────────────────────────────
484
485async function itemFor($: EngineInterface, raw: string, label?: string): Promise<TrayItem | null> {
486  const now = await $.clock.now()
487  if (/^https?:\/\//.test(raw)) {
488    return { target: raw, label: label ?? raw.replace(/^https?:\/\//, ''), kind: 'url', tag: tagOf(raw, rules), addedAt: now }
489  }
490  const path = await expandHome($, raw)
491  try {
492    const stat = await $.fs.stat(path)
493    return {
494      target: path,
495      label: label ?? baseName(path),
496      kind: stat.kind === 'dir' ? 'folder' : kindOf(path),
497      tag: tagOf(path, rules),
498      addedAt: now,
499    }
500  } catch {
501    return null
502  }
503}
504
505async function addItem($: EngineInterface, item: TrayItem, isAsked: boolean) {
506  await update($, items, list => [item, ...list.filter(one => one.target !== item.target)].slice(0, MAX_ITEMS))
507  await update($, selected, () => 0)
508  const isUp = (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced)
509  if (isUp) return
510  const opened = await $.ui.open({ id: PANE, title: 'Open Tray' })
511  if (!opened.isPlaced && isAsked) {
512    $.ui.toast(`Open Tray: ${item.label}  (/tray to show)`)
513  }
514}
515
516// ── The area ─────────────────────────────────────────────────────────────────
517
518// A vote for an area; enough recent votes (or a prompt naming it) switch the tray to it.
519async function vote($: EngineInterface, area: Area, isClear: boolean) {
520  const votes = await update($, signals, list => [...list, area.key].slice(-WINDOW))
521  const r = await read($, related)
522  if (r.area === area.key) return
523  const count = votes.filter(v => v === area.key).length
524  if (isClear || count >= SWITCH_VOTES) {
525    await update($, related, () => ({ ...NO_RELATED, area: area.key, label: area.label }))
526    await update($, hidden, () => [])
527    await refresh($, area)
528  }
529}
530
531// Rebuilds an Area from its key: by the rules' patterns, or as a folder.
532function areaFromKey(key: string, _votes: string[]): Area | null {
533  return areaIn(key, rules) ?? (rules?.areas?.length ? null : { key, label: key, values: { '1': key, '1n': key } })
534}
535
536async function refresh($: EngineInterface, area: Area) {
537  const now = await $.clock.now()
538  lastRefreshAt = now
539  const skip = skipList(rules)
540  const groups: RelatedGroup[] = []
541  for (const rule of relatedPlan(area, rules)) {
542    const run = await $.process.run(['/bin/sh', '-c', findScript(root, rule.paths, rule.names, rule.maxDepth)], { timeoutMs: 15_000 })
543    const found = run.stdout
544      .split('\n')
545      .map(line => line.replace(/^\.\//, ''))
546      .filter(path => path && !skip.some(s => `/${path}`.includes(s)) && (!rule.pattern || rule.pattern.test(path)))
547    const dated: { path: string; mtime: number }[] = []
548    for (const path of found.slice(0, 300)) {
549      try {
550        dated.push({ path, mtime: (await $.fs.stat(`${root}/${path}`)).mtimeMs })
551      } catch {
552        // Gone since the search.
553      }
554    }
555    dated.sort((a, b) => b.mtime - a.mtime)
556    const groupList = groupItems(rule, dated, root, rules, now)
557    if (groupList.length > 0) groups.push({ label: rule.label, items: groupList })
558  }
559  const actions: TrayAction[] = []
560  for (const action of rules?.actions ?? []) {
561    const run = await $.process.run(['/bin/sh', '-c', dirsScript(root, actionPattern(action.each, area))], { timeoutMs: 5_000 })
562    actions.push(...actionsFor(action, area, run.stdout.split('\n').filter(Boolean).sort()))
563  }
564  await update($, related, r => (r.area === area.key ? { ...r, groups, actions } : r))
565}
566
567// Rules decide which shell searches run and which patterns run on your prompts,
568// so only rules you chose are used: your own (~/.claude/open-tray/rules/), or a
569// repo's .claude/tray.json once you have trusted that exact content with
570// `/tray trust` (a changed file asks again).
571async function loadRules($: EngineInterface, projectRoot: string): Promise<Rules | null> {
572  const home = (await $.env.get('HOME')) ?? ''
573  const [repoPath, ownPath] = rulesPaths(projectRoot, home)
574  untrusted = null
575  const own = await readRules($, ownPath!)
576  if (own) return own.rules
577  const repo = await readRules($, repoPath!)
578  if (!repo) return null
579  const trusted = ((await $.store.get('trusted').catch(() => undefined)) ?? {}) as Record<string, string>
580  if (trusted[projectRoot] === repo.hash) return repo.rules
581  untrusted = { path: '.claude/tray.json', hash: repo.hash }
582  $.ui.toast('This repo has Open Tray rules (.claude/tray.json). Review them, then /tray trust to use them.')
583
584  return null
585}
586
587async function readRules($: EngineInterface, path: string): Promise<{ rules: Rules; hash: string } | null> {
588  try {
589    const text = await $.fs.read(path)
590    return { rules: JSON.parse(text) as Rules, hash: await sha256(text) }
591  } catch {
592    return null // Not there, or not valid JSON.
593  }
594}
595
596async function sha256(text: string): Promise<string> {
597  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
598  return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('')
599}
600
601async function trustRepo($: EngineInterface, isTrusting: boolean): Promise<string> {
602  const trusted = ((await $.store.get('trusted').catch(() => undefined)) ?? {}) as Record<string, string>
603  if (!isTrusting) {
604    delete trusted[root]
605    await $.store.set('trusted', trusted)
606    rules = await loadRules($, root)
607    return "Open Tray: this repo's rules are no longer used."
608  }
609  if (!untrusted) return rules ? 'Open Tray: rules already in use.' : 'Open Tray: this repo has no .claude/tray.json.'
610  trusted[root] = untrusted.hash
611  await $.store.set('trusted', trusted)
612  rules = await loadRules($, root)
613  const r = await read($, related)
614  const area = r.area ? areaFromKey(r.area, []) : null
615  if (area) await refresh($, area)
616  return "Open Tray: using this repo's rules. If .claude/tray.json changes, /tray trust again."
617}
618
619// ── Review players ───────────────────────────────────────────────────────────
620
621async function notePlayers($: EngineInterface, text: string, tag: string | null) {
622  const urls = [...new Set(text.match(LOCAL_URL) ?? [])].map(url => url.replace(/[.,]+$/, ''))
623  if (urls.length === 0) return
624  await update($, players, list => {
625    const kept = list.filter(p => !urls.includes(p.url))
626    return [...urls.map(url => ({ url, tag: tag ?? tagOf(url, rules), isAlive: true })), ...kept].slice(0, 12)
627  })
628}
629
630async function readOutputFile($: EngineInterface, path: string, tag: string | null) {
631  try {
632    await notePlayers($, await $.fs.read(path), tag)
633  } catch {
634    // Not written yet, or already cleaned up.
635  }
636}
637
638async function checkPlayers($: EngineInterface) {
639  const list = await read($, players)
640  if (list.length === 0) return
641  const checked: ReviewPlayer[] = []
642  for (const player of list) {
643    const probe = await $.process.run(['curl', '-s', '-o', '/dev/null', '--max-time', '2', '-w', '%{http_code}', player.url], {
644      timeoutMs: 5000,
645    })
646    checked.push({ ...player, isAlive: probe.stdout.trim() !== '000' && probe.stdout.trim() !== '' })
647  }
648  await update($, players, () => checked)
649}
650
651async function restart($: EngineInterface, player: ReviewPlayer) {
652  await $.prompt.submit({ text: `The local server ${player.tag ? `for ${player.tag} ` : ''}(${player.url}) has stopped. Restart it.` })
653}
654
655// ── Helpers ──────────────────────────────────────────────────────────────────
656
657// Paths and URLs a command passes to macOS `open`, following `cd` within the command.
658function openTargets(command: string, cwd: string): string[] {
659  const targets: string[] = []
660  let dir = cwd
661  for (const segment of command.split(/&&|\|\||;|\n|\|/)) {
662    const words = shellWords(segment.trim())
663    const into = words[0] === 'cd' ? words[1] : undefined
664    if (into) {
665      dir = resolvePath(into, dir)
666      continue
667    }
668    if (words[0] !== 'open') continue
669    for (let i = 1; i < words.length; i++) {
670      const word = words[i] ?? ''
671      if (word === '-a' || word === '-b') {
672        i++
673        continue
674      }
675      if (word.startsWith('-')) continue
676      targets.push(/^https?:\/\//.test(word) ? word : resolvePath(word, dir))
677    }
678  }
679
680  return targets
681}
682
683function shellWords(text: string): string[] {
684  const words: string[] = []
685  for (const match of text.matchAll(/"((?:\\.|[^"\\])*)"|'([^']*)'|((?:\\.|[^\s"'\\])+)/g)) {
686    words.push((match[1] ?? match[2] ?? match[3] ?? '').replace(/\\(.)/g, '$1'))
687  }
688
689  return words
690}
691
692function resolvePath(path: string, dir: string): string {
693  if (path.startsWith('/') || path.startsWith('~')) return path
694  const out: string[] = []
695  for (const part of `${dir}/${path}`.split('/')) {
696    if (part === '..') out.pop()
697    else if (part && part !== '.') out.push(part)
698  }
699
700  return `/${out.join('/')}`
701}
702
703async function expandHome($: EngineInterface, path: string): Promise<string> {
704  if (!path.startsWith('~')) return path
705  return ((await $.env.get('HOME')) ?? '') + path.slice(1)
706}
707
708function ago(ms: number): string {
709  const minutes = Math.round(ms / 60_000)
710  if (minutes < 1) return 'now'
711  if (minutes < 60) return `${minutes}m`
712  if (minutes < 60 * 24) return `${Math.round(minutes / 60)}h`
713
714  return `${Math.round(minutes / (60 * 24))}d`
715}
716
717function clip(text: string, room: number): string {
718  return text.length <= room ? text : `…${text.slice(text.length - room + 1)}`
719}
720
hooks/rules.ts 232 lines
1// What the tray knows about a project: an optional rules file says how to tell
2// which area we're working on and where that area's good stuff lives. Without
3// one, the area is the folder Claude has been working in, and "related" is
4// the newest media, pages and PDFs inside it.
5//
6// Rules are read from the project's own `.claude/tray.json`, else from the
7// person's own `~/.claude/open-tray/rules/<repo folder name>.json`. The plugin
8// ships no project's rules, only `examples/tray.json` to copy from.
9
10import type { TrayAction, TrayItem, TrayKind } from '../types'
11
12export type AreaRule = { key: string; label: string; patterns: string[]; pad?: number }
13export type RelatedRule = {
14  label: string
15  paths: string[]
16  names: string[]
17  as?: 'file' | 'folder'
18  /** Show one per folder (its index.html when there is one). */
19  onePerFolder?: boolean
20  pathMatch?: string
21  maxDepth?: number
22  max?: number
23}
24export type Rules = {
25  areas?: AreaRule[]
26  tag?: string
27  skip?: string[]
28  related?: RelatedRule[]
29  actions?: { label: string; each: string; prompt: string }[]
30  servers?: { spot: string }
31}
32
33const DEFAULT_SKIP = ['/node_modules/', '/.git/', '/dist/', '/build/', '/.next/', '/archive/', '/.cache/']
34const DEFAULT_NAMES = ['*.mp4', '*.mov', '*.webm', '*.mp3', '*.wav', '*.m4a', '*.png', '*.jpg', '*.jpeg', '*.gif', '*.pdf', '*.html']
35
36export const KINDS: Record<string, TrayKind> = {
37  mp4: 'video', mov: 'video', webm: 'video', m4v: 'video',
38  mp3: 'audio', wav: 'audio', m4a: 'audio', aac: 'audio', flac: 'audio', ogg: 'audio',
39  png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', svg: 'image',
40  html: 'page', htm: 'page',
41  pdf: 'doc', md: 'doc', txt: 'doc', json: 'doc',
42}
43
44/** Where a project's rules may be: its own file first, then the person's own rules folder. */
45export function rulesPaths(root: string, home: string): string[] {
46  return [`${root}/.claude/tray.json`, `${home}/.claude/open-tray/rules/${baseName(root)}.json`]
47}
48
49/**
50 * Commands that start a local server, for every project: package-manager dev,
51 * start, serve and preview scripts, and the common servers run directly.
52 * A project's rules may add its own (`servers.spot`).
53 */
54export const DEFAULT_SERVER_SPOT =
55  '\\b(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?(?:dev|start|serve|preview|storybook)\\b|\\b(?:vite|next\\s+dev|astro\\s+dev|http-server|live-server|serve)\\b|\\bpython3?\\s+-m\\s+http\\.server\\b'
56
57/** Does this command start a server worth watching? */
58export function startsServer(command: string, rules: Rules | null): boolean {
59  const text = command.slice(0, MAX_TEXT)
60  if (new RegExp(DEFAULT_SERVER_SPOT).test(text)) return true
61  return Boolean(regexOf(rules?.servers?.spot)?.test(text))
62}
63
64// Best effort only: no check can tell every slow pattern from a fast one, so
65// the boundary is that rules run only once chosen (your own file, or a repo's
66// you trusted with /tray trust). Within that, text is cut short, and a pattern
67// that is too long, invalid, or nests quantifiers (`(a+)+`) is ignored.
68const MAX_TEXT = 2000
69const MAX_PATTERN = 300
70const NESTED_QUANTIFIER = /\((?:[^()\\]|\\.)*[+*}](?:[^()\\]|\\.)*\)\s*[+*{]/
71const compiled = new Map<string, RegExp | null>()
72
73export function regexOf(pattern: unknown, flags = ''): RegExp | null {
74  if (typeof pattern !== 'string' || !pattern || pattern.length > MAX_PATTERN) return null
75  const key = `${flags}/${pattern}`
76  if (!compiled.has(key)) {
77    let re: RegExp | null = null
78    if (!NESTED_QUANTIFIER.test(pattern)) {
79      try {
80        re = new RegExp(pattern, flags)
81      } catch {
82        re = null
83      }
84    }
85    compiled.set(key, re)
86  }
87  return compiled.get(key) ?? null
88}
89
90/** A search depth from the rules, whatever they hold: a whole number from 1 to 12. */
91export function depthOf(value: unknown, fallback = 6): number {
92  const n = Math.floor(Number(value))
93  return Number.isFinite(n) ? Math.min(12, Math.max(1, n)) : fallback
94}
95
96export type Area = { key: string; label: string; values: Record<string, string> }
97
98/** The area a piece of text (a prompt, a path) points at, by the rules' patterns. */
99export function areaIn(text: string, rules: Rules | null): Area | null {
100  for (const rule of rules?.areas ?? []) {
101    for (const pattern of rule.patterns) {
102      const match = regexOf(pattern, 'i')?.exec(text.slice(0, MAX_TEXT))
103      const raw = match?.slice(1).find(Boolean)
104      if (!raw) continue
105      const n = String(Number(raw))
106      const padded = n.padStart(rule.pad ?? 0, '0')
107      const values = { '1': padded, '1n': n }
108      return { key: fill(rule.key, values), label: fill(rule.label, values), values }
109    }
110  }
111
112  return null
113}
114
115/** Without rules: the folder (two levels under the repo root) a path sits in. */
116export function folderArea(path: string, root: string): Area | null {
117  if (!path.startsWith(`${root}/`)) return null
118  const parts = path.slice(root.length + 1).split('/').slice(0, -1).slice(0, 2)
119  if (parts.length === 0) return null
120  const key = parts.join('/')
121
122  return { key, label: key, values: { '1': key, '1n': key } }
123}
124
125export function tagOf(text: string, rules: Rules | null): string | null {
126  if (!rules?.tag) return null
127  return regexOf(rules.tag, 'i')?.exec(text.slice(0, MAX_TEXT))?.[0].toLowerCase() ?? null
128}
129
130export function kindOf(path: string): TrayKind {
131  return KINDS[path.split('.').pop()?.toLowerCase() ?? ''] ?? 'other'
132}
133
134export function baseName(path: string): string {
135  return path.replace(/\/+$/, '').split('/').pop() ?? path
136}
137
138/** The searches for an area: the rules' groups, or the generic newest-in-this-folder scan. */
139export function relatedPlan(area: Area, rules: Rules | null): (RelatedRule & { pattern: RegExp | null })[] {
140  const list: RelatedRule[] = rules?.related?.length
141    ? rules.related
142    : [{ label: 'Newest here', paths: [area.key], names: DEFAULT_NAMES, maxDepth: 4, max: 8 }]
143
144  return list.map(rule => ({
145    ...rule,
146    paths: rule.paths.map(p => fill(p, area.values)),
147    names: rule.names.map(n => fill(n, area.values)),
148    // The area's values come from prompts and paths: escaped, so they match as text.
149    pattern: rule.pathMatch ? regexOf(fill(rule.pathMatch, escapedValues(area.values)), 'i') : null,
150  }))
151}
152
153export function skipList(rules: Rules | null): string[] {
154  return [...DEFAULT_SKIP, ...(rules?.skip ?? [])]
155}
156
157/** Builds a group from search results (paths relative to the root, newest first). */
158export function groupItems(
159  rule: RelatedRule,
160  dated: { path: string; mtime: number }[],
161  root: string,
162  rules: Rules | null,
163  now: number,
164): TrayItem[] {
165  const dirOf = (path: string) => path.split('/').slice(0, -1).join('/')
166  // One per folder: the folder's index.html wins, else its newest file.
167  const picked = rule.onePerFolder
168    ? [...new Map(dated.map(d => [dirOf(d.path), dated.find(x => dirOf(x.path) === dirOf(d.path) && /\/index\.html?$/i.test(x.path)) ?? d])).values()]
169    : dated
170  const targets: { target: string; mtime: number }[] = []
171  const seen = new Set<string>()
172  for (const { path, mtime } of picked) {
173    const target = rule.as === 'folder' ? dirOf(path) : path
174    if (seen.has(target)) continue
175    seen.add(target)
176    targets.push({ target, mtime })
177  }
178  // As folders, keep the deepest: a folder that holds another listed one is dropped.
179  const kept = rule.as === 'folder' ? targets.filter(t => !targets.some(o => o.target.startsWith(`${t.target}/`))) : targets
180
181  return kept.slice(0, rule.max ?? 6).map(({ target, mtime }) => ({
182    target: `${root}/${target}`,
183    label: rule.as === 'folder' ? `${baseName(target)}/` : baseName(target),
184    kind: rule.as === 'folder' ? 'folder' : kindOf(target),
185    tag: tagOf(target, rules),
186    addedAt: Math.min(now, mtime),
187  }))
188}
189
190/** The actions for an area (one per matching folder), from the folders the shell found. */
191export function actionsFor(action: { label: string; prompt: string }, area: Area, dirs: string[]): TrayAction[] {
192  return dirs.map(dir => {
193    const values = { ...area.values, each: baseName(dir) }
194    return { label: fill(action.label, values), prompt: fill(action.prompt, values) }
195  })
196}
197
198export function actionPattern(each: string, area: Area): string {
199  return fill(each, area.values)
200}
201
202/** `find` over shell-expanded paths (the rules' own globs), names matched case-insensitively. */
203export function findScript(root: string, paths: string[], names: string[], depth: unknown): string {
204  // Everything from the rules is quoted; the depth goes in bare, so it is a number here.
205  const nameTest = names.map(n => `-iname ${quote(n)}`).join(' -o ')
206  return `cd ${quote(root)} && for d in ${paths.map(globWord).join(' ')}; do [ -d "$d" ] && find "$d" -maxdepth ${depthOf(depth)} \\( -type f -o -type l \\) \\( ${nameTest} \\) -print; done 2>/dev/null | head -2000`
207}
208
209export function dirsScript(root: string, pattern: string): string {
210  return `cd ${quote(root)} && for d in ${globWord(pattern)}; do [ -d "$d" ] && echo "$d"; done`
211}
212
213function fill(template: string, values: Record<string, string>): string {
214  return template.replace(/\{(\w+)\}/g, (whole, name: string) => values[name] ?? whole)
215}
216
217function escapedValues(values: Record<string, string>): Record<string, string> {
218  return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')]))
219}
220
221function quote(text: string): string {
222  return `'${text.replace(/'/g, `'\\''`)}'`
223}
224
225// Quotes everything but the glob characters, so the shell expands `*` and nothing else.
226function globWord(pattern: string): string {
227  return pattern
228    .split(/(\*|\?)/)
229    .map(part => (part === '*' || part === '?' ? part : part ? quote(part) : ''))
230    .join('')
231}
232
types/index.d.ts 47 lines
1export type TrayKind = 'video' | 'audio' | 'image' | 'page' | 'doc' | 'folder' | 'url' | 'other'
2
3export type TrayItem = {
4  target: string
5  label: string
6  kind: TrayKind
7  /** A short tag from the project's rules (e.g. a chapter or ticket id), or null. */
8  tag: string | null
9  addedAt: number
10}
11
12/** Something the tray can start for the person, e.g. a review player. */
13export type TrayAction = { label: string; prompt: string }
14
15export type RelatedGroup = { label: string; items: TrayItem[] }
16
17/** "Related to what we're on": swapped out when the area changes. */
18export type Related = {
19  /** The area's key (from the rules, e.g. "ch02", or a folder), null before any work. */
20  area: string | null
21  label: string
22  groups: RelatedGroup[]
23  actions: TrayAction[]
24  /** Items Claude suggested for this area; they go when the area changes. */
25  suggested: TrayItem[]
26}
27
28export type ReviewPlayer = {
29  url: string
30  tag: string | null
31  isAlive: boolean
32}
33
34declare module 'claude-code' {
35  interface PluginState {
36    'open-tray': {
37      items: TrayItem[]
38      selected: number
39      players: ReviewPlayer[]
40      related: Related
41      /** Recent area votes, newest last: from prompts and the files Claude touches. */
42      signals: string[]
43      hidden: string[]
44    }
45  }
46}
47