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…

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.
offer tool. Stays until you remove it.node_modules, builds, .git and archives).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).
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.
hooks/register.tsx 720 lines1// 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}
720hooks/rules.ts 232 lines1// 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}
232types/index.d.ts 47 lines1export 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