SLOPSHOPPER

output-tray

A side panel of the files this session created, with Open, Reveal in Finder and Copy path.

newpaneguardcommandtoolprocess
★ 11v0.1.1NOASSERTIONupdated 2026-10-02promptadvisers/claude-mods-starter-kit/plugins/output-tray
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · output-tray
│ ┃ Output Tray ✕ › fix the failing auth test and add an audit log call │ ┃ OUTPUT TRAY │ ┃ 2 files created this session ⏺ Read(src/auth.ts) │ ┃ [ o Open ] [ r Reveal ] [ c Copy path ] [ f ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ 1: ▶ cache.ts missing: deleted or moved Writ ⎿ Added 2 lines, removed 1 line │ ┃ src/ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ 2: · audit.ts missing: deleted or moved Writ │ ┃ src/ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ cache.ts is a script: Open shows it as text ✻ Worked for 42s · done 4:20 PM │ ┃ and never runs it. │ › /tray │ ⎿ output-tray: Output Tray open: 2 files. Keys 1-9 pick, o open, r │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Output Tray
OUTPUT TRAY 2 files created this session [ o Open ] [ r Reveal ] [ c Copy path ] [ f Refresh ] [ x Cl 1: ▶ cache.ts missing: deleted or moved Write src/ 2: · audit.ts missing: deleted or moved Write src/ cache.ts is a script: Open shows it as text and never runs it.
README

Output Tray

Collects new files created during the session in a panel with copy, open and reveal actions.

See the complete guide and creation prompt.

Test from the kit root:

claude plugin validate plugins/output-tray
claude plugin test plugins/output-tray
Source 2 files
hooks/output-tray.mjs 755 lines
1// Output Tray: the files this session CREATED, in a side pane, with Open, Reveal
2// in Finder and Copy path. Nothing is ever opened unless the person presses a
3// button or types a /tray command; the model can show or list the tray, never
4// open a file.
5//
6// Tracking, three ways, all honest about what they saw:
7//   - Write / NotebookEdit: the path is checked BEFORE the call (`$.fs.exists`);
8//     after `next(e)` it counts only when the call was not denied, not errored,
9//     not staged, and the tool itself did not report an update. A Write over an
10//     existing file goes to a separate "edited existing" count.
11//   - Bash: a snapshot of new files under the session's folder (`git status
12//     --porcelain -z --untracked-files=all`, or a bounded `find` outside git)
13//     before and after each call; the difference is tagged "shell".
14//   - Turn sweep: the same snapshot at turn start and end catches what neither
15//     saw (a background command, an MCP tool), tagged "other".
16// A call with `e.agentId` came from a subagent and is tagged so.
17
18import { update } from "claude-code";
19
20const PANE = "output-tray";
21const TITLE = "Output Tray";
22const TOOL = "output_tray";
23const TOOL_FULL = "mcp__output-tray__output_tray";
24const MAX_ITEMS = 300;
25const MAX_MODIFIED = 100;
26const TICK_MS = 15000;
27const SHEBANG_READ_MAX = 1024 * 1024;
28
29const itemsRef = { plugin: "output-tray", key: "items" };
30const metaRef = { plugin: "output-tray", key: "meta" };
31
32const EMPTY_META = { cwd: "", seq: 0, selected: "", modified: [], lastAction: "" };
33
34// Opening these with `open` could run, install, mount or launch something, so
35// Open reveals them in Finder instead.
36const REVEAL_ONLY = new Set([
37  "app", "pkg", "mpkg", "dmg", "iso", "jar", "workflow", "action", "prefpane", "kext", "plugin", "bundle",
38  "saver", "mobileconfig", "shortcut", "exe", "msi", "appex", "framework", "scpt", "scptd", "osax", "service",
39  "qlgenerator", "mdimporter", "xpc", "dylib", "so", "bin", "run", "out", "elf", "deb", "rpm", "appimage",
40]);
41// Scripts and link files: `open` might run them (a .command or .sh opens in
42// Terminal and runs; Python Launcher runs a .py), so Open shows them as text.
43const TEXT_ONLY = new Set([
44  "sh", "bash", "zsh", "fish", "ksh", "csh", "tcsh", "command", "tool", "py", "pyw", "rb", "pl", "php", "lua",
45  "js", "mjs", "cjs", "ts", "tsx", "jsx", "ps1", "psm1", "bat", "cmd", "vbs", "applescript", "terminal",
46  "webloc", "inetloc", "url", "fileloc", "desktop", "swift", "r", "tcl", "awk", "expect",
47]);
48
49const VIA_LABEL = { write: "Write", notebook: "Notebook", shell: "shell", other: "other" };
50
51const HELP = [
52  "Output Tray: the files Claude created in this session.",
53  "  /tray              open or close the tray panel",
54  "  /tray list         list the files as text",
55  "  /tray open <n>     open file n (scripts open as text, apps and installers are revealed instead)",
56  "  /tray reveal <n>   show file n in Finder",
57  "  /tray copy <n>     copy file n's full path",
58  "  /tray clear        forget the list (files on disk are untouched)",
59  "  /tray close        close the panel",
60  "In the panel: 1-9 pick a row, o Open, r Reveal, c Copy path, f Refresh, x Close. Esc gives the keys back.",
61  "Nothing is ever opened automatically. Claude can show or list the tray, never open a file.",
62].join("\n");
63
64export function register(on) {
65  // Module memory: reset on a hot reload, which only costs one turn sweep.
66  const mem = { inflight: new Set(), roots: new Map(), turnBase: null, ticker: null };
67
68  on("session.start", async ($, e, next) => {
69    const started = await next(e);
70    try {
71      await $.command.register({
72        name: "tray",
73        description: "Output Tray: the files Claude created this session, with Open, Reveal and Copy path",
74        argumentHint: "[list|open <n>|reveal <n>|copy <n>|clear|close|help]",
75        immediate: true,
76      });
77    } catch {
78      // registered by an earlier load
79    }
80    try {
81      await $.tool.register({
82        name: TOOL,
83        description:
84          "Show the user their Output Tray (a side panel listing the files created in this session, newest first, " +
85          "with buttons the USER presses to open or reveal them), or list those files as text. Use when the user asks " +
86          "to see the files you made, the outputs, or the deliverables. action 'show' opens the panel; 'list' returns " +
87          "the list. This tool cannot open files: opening is the user's own action in the panel.",
88        inputSchema: {
89          type: "object",
90          properties: {
91            action: { type: "string", enum: ["show", "list"], description: "show: open the panel. list: return the files as text." },
92          },
93          required: ["action"],
94        },
95      });
96    } catch {
97      // registered by an earlier load
98    }
99    const { value: m = EMPTY_META } = await $.state.get(metaRef);
100    if (e.cwd && m.cwd !== e.cwd) await $.state.set(metaRef, { ...m, cwd: e.cwd });
101    return started;
102  });
103
104  // A /clear ends the conversation; the tray starts over with the next one.
105  on("session.end", async ($, e, next) => {
106    if (e.reason === "clear") {
107      await $.state.set(itemsRef, []);
108      const { value: m = EMPTY_META } = await $.state.get(metaRef);
109      await $.state.set(metaRef, { ...m, selected: "", modified: [], lastAction: "" });
110    }
111    return next(e);
112  });
113
114  // ---- tracking ------------------------------------------------------------------
115
116  on("tool.call", { tool: "Write" }, async ($, e, next) => trackFileTool($, e, next, e.file_path, "write", mem));
117  on("tool.call", { tool: "NotebookEdit" }, async ($, e, next) => trackFileTool($, e, next, e.notebook_path, "notebook", mem));
118
119  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
120    const cwd = await cwdOf($);
121    const before = await snapshot($, cwd, mem.roots);
122    const ran = await next(e);
123    if (!before || ran.deny !== undefined) return ran;
124    try {
125      const after = await snapshot($, cwd, mem.roots);
126      if (after) {
127        const fresh = await newPaths($, before, after, mem.inflight);
128        if (fresh.length) await addItems($, fresh, "shell", e.agentId ?? "");
129      }
130    } catch {
131      // ignore
132    }
133    return ran;
134  });
135
136  on("turn.start", async ($, e, next) => {
137    if (e.agentId == null) {
138      try {
139        mem.turnBase = await snapshot($, await cwdOf($), mem.roots);
140      } catch {
141        mem.turnBase = null;
142      }
143    }
144    return next(e);
145  });
146
147  on("turn.complete", async ($, e, next) => {
148    const result = await next(e);
149    if (e.agentId != null || !mem.turnBase) return result;
150    const base = mem.turnBase;
151    mem.turnBase = null;
152    try {
153      const after = await snapshot($, await cwdOf($), mem.roots);
154      if (after) {
155        const fresh = await newPaths($, base, after, mem.inflight);
156        if (fresh.length) await addItems($, fresh, "other", "");
157      }
158    } catch {
159      // ignore
160    }
161    return result;
162  });
163
164  // ---- the model's tool: show or list, never open ------------------------------------
165
166  on("tool.call", { tool: TOOL_FULL }, async ($, e) => {
167    const action = String(e.action ?? "show").toLowerCase();
168    const { value: items = [] } = await $.state.get(itemsRef);
169    if (action === "list") return { result: await listing($, items) };
170    if (action !== "show") return { result: `Unknown action "${action}". Use "show" or "list".` };
171    let placed = false;
172    try {
173      placed = (await $.ui.open({ id: PANE, title: TITLE })).isPlaced;
174    } catch {
175      placed = false;
176    }
177    ensureTicker($, mem);
178    const count = `${items.length} file${items.length === 1 ? "" : "s"}`;
179    if (!placed) {
180      return { result: `The Output Tray panel could not be placed here, so here is the list instead.\n${await listing($, items)}` };
181    }
182    return {
183      result:
184        `Output Tray is open beside the conversation with ${count}. The user opens or reveals files with its buttons ` +
185        "(or /tray open <n>); you cannot open files with this tool.",
186    };
187  });
188
189  // ---- /tray ---------------------------------------------------------------------------
190
191  on("command.run", { command: "tray" }, async ($, e) => {
192    const args = (e.args ?? "").trim();
193    const [verbRaw = "", nRaw = ""] = args.split(/\s+/);
194    const verb = verbRaw.toLowerCase();
195    const { value: items = [] } = await $.state.get(itemsRef);
196
197    if (verb === "help" || verb === "?") return { text: HELP };
198    if (verb === "list" || verb === "ls") return { text: await listing($, items) };
199
200    if (verb === "clear") {
201      await $.state.set(itemsRef, []);
202      const { value: m = EMPTY_META } = await $.state.get(metaRef);
203      await $.state.set(metaRef, { ...m, selected: "", modified: [], lastAction: "" });
204      return { text: `Output Tray cleared (${items.length} entr${items.length === 1 ? "y" : "ies"} forgotten; files on disk untouched).` };
205    }
206
207    if (verb === "close") {
208      await $.ui.close({ id: PANE });
209      return { text: "Output Tray closed." };
210    }
211
212    if ((verb === "open" || verb === "reveal" || verb === "copy") && nRaw) {
213      const n = Number.parseInt(nRaw, 10);
214      const item = Number.isFinite(n) ? items[n - 1] : undefined;
215      if (!item) return { text: `No file ${nRaw} in the tray (it has ${items.length}). Try /tray list.` };
216      if (verb === "copy") {
217        const copied = await $.ui.copy({ text: item.abs });
218        return { text: copied.isCopied ? `Copied: ${item.abs}` : `Could not copy (${copied.reason}). Path: ${item.abs}` };
219      }
220      const done = verb === "open" ? await openItem($, item) : await revealItem($, item);
221      return { text: done };
222    }
223
224    if (verb !== "" && verb !== "show" && verb !== "open") {
225      return { text: `Unknown /tray option "${verbRaw}".\n${HELP}` };
226    }
227
228    const pane = (await $.ui.panes()).find((p) => p.id === PANE);
229    if (verb === "" && pane && pane.isShown) {
230      await $.ui.close({ id: PANE });
231      return { text: "Output Tray closed." };
232    }
233    const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true });
234    ensureTicker($, mem);
235    if (!opened.isPlaced) {
236      return { text: `Output Tray could not be placed (${opened.reason}).\n${await listing($, items)}` };
237    }
238    return {
239      text: `Output Tray open: ${items.length} file${items.length === 1 ? "" : "s"}. Keys 1-9 pick, o open, r reveal, c copy path, x close.`,
240    };
241  });
242
243  // ---- the pane ------------------------------------------------------------------------
244
245  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
246    const { Box, Text, Button } = $.ui.resolve(e);
247    const { value: items = [] } = await $.state.get(itemsRef);
248    const { value: m = EMPTY_META } = await $.state.get(metaRef);
249    const width = Math.max(30, Math.floor(e.props.bodyColumns ?? 60));
250    const now = await $.clock.now();
251    const dim = (children, extra = {}) => Text({ dimColor: true, wrap: "truncate", children, ...extra });
252    ensureTicker($, mem);
253
254    const rows = [];
255    const created = `${items.length} file${items.length === 1 ? "" : "s"} created this session`;
256    const edited = m.modified.length ? `  ·  ${m.modified.length} existing edited` : "";
257    rows.push(Text({ bold: true, wrap: "truncate", children: "OUTPUT TRAY" }));
258    rows.push(dim(clip(created + edited, width)));
259
260    if (items.length === 0) {
261      rows.push(Text({ children: " " }));
262      rows.push(dim("Nothing created yet. Files Claude makes with Write, notebooks or shell commands show up here, newest first."));
263      rows.push(Box({ flexDirection: "row", gap: 1, children: [closeButton($, Button)] }));
264      return Box({ flexDirection: "column", children: rows });
265    }
266
267    const selectedId = items.some((x) => x.id === m.selected) ? m.selected : items[0].id;
268    const room = Math.max(3, Math.floor(((e.viewport?.rows ?? 40) - 12) / 3));
269    const shown = items.slice(0, room);
270    const stats = await Promise.all(shown.map((x) => statOf($, x.abs)));
271
272    rows.push(
273      Box({
274        flexDirection: "row",
275        gap: 1,
276        children: [
277          Button({ key: "act:open", label: "o Open", hotkey: "o", variant: "primary", onPress: () => actOnSelected($, "open") }),
278          Button({ key: "act:reveal", label: "r Reveal", hotkey: "r", onPress: () => actOnSelected($, "reveal") }),
279          Button({ key: "act:copy", label: "c Copy path", hotkey: "c", onPress: (p) => actOnSelected($, "copy", p.surface) }),
280          Button({ key: "act:refresh", label: "f Refresh", hotkey: "f", onPress: () => refresh($) }),
281          closeButton($, Button),
282        ],
283      }),
284    );
285
286    shown.forEach((item, i) => {
287      const st = stats[i];
288      const isSel = item.id === selectedId;
289      const hotkey = i < 9 ? String(i + 1) : undefined;
290      const tag = tagOf(item);
291      const meta = st ? `${sizeOf(st.size)}  ${ago(now - item.at)}` : "missing: deleted or moved";
292      rows.push(
293        Box({
294          key: `row:${item.id}`,
295          flexDirection: "row",
296          gap: 1,
297          marginTop: 1,
298          children: [
299            Button({
300              key: `pick:${item.id}`,
301              label: isSel ? "▶" : "·",
302              hotkey,
303              plain: true,
304              onPress: () => select($, item.id),
305            }),
306            Text({ bold: true, color: isSel ? "cyan" : undefined, strikethrough: !st, wrap: "truncate", children: clip(item.name, Math.max(10, width - 34)) }),
307            Text({ color: st ? undefined : "yellow", dimColor: Boolean(st), wrap: "truncate", children: meta }),
308            Text({ color: tagColor(item), wrap: "truncate", children: tag }),
309          ],
310        }),
311      );
312      const buttons = st
313        ? [
314            Button({ key: `open:${item.id}`, label: "Open", dimColor: !isSel, onPress: () => select($, item.id).then(() => openItem($, item)) }),
315            Button({ key: `reveal:${item.id}`, label: "Reveal", dimColor: !isSel, onPress: () => select($, item.id).then(() => revealItem($, item)) }),
316          ]
317        : [];
318      rows.push(
319        Box({
320          flexDirection: "row",
321          gap: 1,
322          children: [Text({ children: "   " }), dim(clip(item.folder, Math.max(8, width - 24))), ...buttons],
323        }),
324      );
325    });
326
327    if (items.length > shown.length) rows.push(dim(`+${items.length - shown.length} more: /tray list`));
328
329    const sel = shown.find((x) => x.id === selectedId);
330    if (sel && openPlanByName(sel.abs) !== "open") {
331      rows.push(Text({ children: " " }));
332      rows.push(
333        Text({
334          color: "yellow",
335          wrap: "wrap",
336          children: `${sel.name} is ${openPlanByName(sel.abs) === "text" ? "a script: Open shows it as text and never runs it" : "an app, installer or package: Open reveals it in Finder instead"}.`,
337        }),
338      );
339    }
340    if (m.lastAction) {
341      rows.push(Text({ children: " " }));
342      rows.push(dim(clip(m.lastAction, width * 2), { wrap: "wrap" }));
343    }
344    return Box({ flexDirection: "column", children: rows });
345  });
346
347  on("ui.close", async ($, e, next) => {
348    const r = await next(e);
349    if (e.id === PANE && mem.ticker) {
350      mem.ticker.cancel();
351      mem.ticker = null;
352    }
353    return r;
354  });
355}
356
357// ---- tracking a file tool -----------------------------------------------------------
358
359async function trackFileTool($, e, next, path, via, mem) {
360  if (typeof path !== "string" || !path) return next(e);
361  let existed = true;
362  try {
363    existed = await $.fs.exists(path);
364  } catch {
365    existed = true; // unknown: never claim it was created
366  }
367  const key = keyOf(path);
368  if (!existed) mem.inflight.add(key);
369  let ran;
370  try {
371    ran = await next(e);
372  } catch (err) {
373    mem.inflight.delete(key);
374    throw err;
375  }
376  try {
377    const verdict = writeVerdict(existed, ran);
378    if (verdict === "created") {
379      // NotebookEdit has no create flag: the file must be there now.
380      const there = via === "write" || (await $.fs.exists(path).catch(() => false));
381      if (there) await addItems($, [path], via, e.agentId ?? "");
382    } else if (verdict === "modified") {
383      await noteModified($, path);
384    }
385  } catch {
386    // the tray is a convenience; never fail the tool over it
387  }
388  mem.inflight.delete(key);
389  return ran;
390}
391
392function ensureTicker($, mem) {
393  if (mem.ticker) return;
394  mem.ticker = $.clock.every(TICK_MS, async () => {
395    let up = false;
396    try {
397      up = (await $.ui.panes()).some((p) => p.id === PANE);
398    } catch {
399      up = false;
400    }
401    if (up) $.ui.invalidate("ui.render");
402    else if (mem.ticker) {
403      mem.ticker.cancel();
404      mem.ticker = null;
405    }
406  });
407}
408
409// ---- actions (only ever from a press or a /tray command) -------------------------------
410
411function closeButton($, Button) {
412  return Button({ key: "act:close", label: "x Close", hotkey: "x", role: "dismiss", onPress: () => $.ui.close({ id: PANE }) });
413}
414
415async function select($, id) {
416  await update($, metaRef, (m) => ({ ...(m ?? EMPTY_META), selected: id }));
417}
418
419async function refresh($) {
420  $.ui.invalidate("ui.render");
421}
422
423async function actOnSelected($, action, surface) {
424  const { value: items = [] } = await $.state.get(itemsRef);
425  const { value: m = EMPTY_META } = await $.state.get(metaRef);
426  const item = items.find((x) => x.id === m.selected) ?? items[0];
427  if (!item) return;
428  if (action === "open") return openItem($, item);
429  if (action === "reveal") return revealItem($, item);
430  const copied = await $.ui.copy(surface ? { text: item.abs, surface } : { text: item.abs });
431  await noteAction($, copied.isCopied ? `Copied path of ${item.name}` : `Could not copy (${copied.reason}): ${item.abs}`);
432}
433
434async function openItem($, item) {
435  const st = await statOf($, item.abs, true);
436  if (!st) return noteAction($, `${item.name} is missing: deleted or moved.`);
437  const plan = await openPlan($, item.abs, st);
438  const argv = plan === "open" ? ["open", item.abs] : plan === "text" ? ["open", "-t", item.abs] : ["open", "-R", item.abs];
439  const code = await run($, argv);
440  const how =
441    plan === "open"
442      ? `Opened ${item.name}`
443      : plan === "text"
444        ? `Opened ${item.name} as text (a script is never run from the tray)`
445        : `Revealed ${item.name} in Finder (an app, installer or executable is never launched from the tray)`;
446  return noteAction($, code === 0 ? how : `open failed for ${item.name} (exit ${code})`);
447}
448
449async function revealItem($, item) {
450  const st = await statOf($, item.abs);
451  if (!st) return noteAction($, `${item.name} is missing: deleted or moved.`);
452  const code = await run($, ["open", "-R", item.abs]);
453  return noteAction($, code === 0 ? `Revealed ${item.name} in Finder` : `Reveal failed for ${item.name} (exit ${code})`);
454}
455
456async function run($, argv) {
457  let code = -1;
458  try {
459    const r = await $.process.run(argv, { timeoutMs: 10000 });
460    code = r.exitCode;
461  } catch (err) {
462    $.ui.log(`output-tray: ${JSON.stringify(argv)} could not start: ${String(err?.message ?? err)}`, { to: "debug" });
463    return -1;
464  }
465  $.ui.log(`output-tray: ran ${JSON.stringify(argv)} exit ${code}`, { to: "debug" });
466  return code;
467}
468
469async function noteAction($, text) {
470  await update($, metaRef, (m) => ({ ...(m ?? EMPTY_META), lastAction: text }));
471  return text;
472}
473
474// What Open does for a path: "open" (its default app), "text" (`open -t`), or
475// "reveal" (`open -R`). Decided at press time from the name, the kind, the
476// execute bit and a shebang.
477async function openPlan($, abs, st) {
478  const byName = openPlanByName(abs);
479  if (byName !== "open") return byName;
480  if (st.realPath && openPlanByName(st.realPath) !== "open") return openPlanByName(st.realPath);
481  if (st.kind !== "file") return "reveal";
482  let isExec = true;
483  try {
484    isExec = (await $.process.run(["test", "-x", abs], { timeoutMs: 5000 })).exitCode === 0;
485  } catch {
486    isExec = true; // unknown: be careful
487  }
488  if (!isExec) return "open";
489  if (st.size <= SHEBANG_READ_MAX) {
490    try {
491      const head = await $.fs.read(abs);
492      if (typeof head === "string" && head.startsWith("#!")) return "text";
493    } catch {
494      // unreadable
495    }
496  }
497  return "reveal";
498}
499
500// The outermost bundle (Hello.app, a .pkg folder) a path sits in, or the path.
501export function bundleOf(abs) {
502  const parts = String(abs).split("/");
503  for (let i = 1; i < parts.length; i++) {
504    const seg = parts[i].toLowerCase();
505    const dot = seg.lastIndexOf(".");
506    if (dot > 0 && REVEAL_ONLY.has(seg.slice(dot + 1))) return parts.slice(0, i + 1).join("/");
507  }
508  return abs;
509}
510
511export function openPlanByName(abs) {
512  const base = basename(abs).toLowerCase();
513  const dot = base.lastIndexOf(".");
514  const ext = dot > 0 ? base.slice(dot + 1) : "";
515  if (REVEAL_ONLY.has(ext)) return "reveal";
516  if (TEXT_ONLY.has(ext)) return "text";
517  return "open";
518}
519
520// ---- tracking helpers -------------------------------------------------------------------
521
522export function writeVerdict(existed, ran) {
523  if (!ran || ran.deny !== undefined || ran.isError) return "none";
524  const r = ran.result;
525  if (r && typeof r === "object" && r.staged) return "none";
526  if (existed) return "modified";
527  if (r && typeof r === "object" && r.type === "update") return "modified";
528  return "created";
529}
530
531async function addItems($, paths, via, agentId) {
532  const { value: m0 = EMPTY_META } = await $.state.get(metaRef);
533  const cwd = m0.cwd || (await $.session.cwd().catch(() => ""));
534  const now = await $.clock.now();
535  const fresh = [];
536  let seq = m0.seq;
537  for (const abs of paths) {
538    const st = await statOf($, abs);
539    if (st && st.kind !== "file" && bundleOf(abs) !== abs) continue;
540    seq += 1;
541    fresh.push(makeItem(abs, cwd, { id: `f${seq}`, via, agentId, at: now, size: st ? st.size : 0 }));
542  }
543  if (!fresh.length) return;
544  await update($, metaRef, (m) => ({ ...(m ?? EMPTY_META), seq: Math.max(seq, (m ?? EMPTY_META).seq), cwd: (m ?? EMPTY_META).cwd || cwd }));
545  const keys = new Set(fresh.map((x) => x.key));
546  const newestFirst = [...fresh].reverse();
547  await update($, itemsRef, (list) => [...newestFirst, ...(list ?? []).filter((x) => !keys.has(x.key))].slice(0, MAX_ITEMS));
548  await update($, metaRef, (m) => ({ ...(m ?? EMPTY_META), selected: newestFirst[0].id }));
549}
550
551async function noteModified($, path) {
552  const { value: m0 = EMPTY_META } = await $.state.get(metaRef);
553  const cwd = m0.cwd || "";
554  const shown = relOf(path, cwd) ?? shortHome(path);
555  await update($, metaRef, (m) => {
556    const cur = m ?? EMPTY_META;
557    if (cur.modified.includes(shown)) return cur;
558    return { ...cur, modified: [...cur.modified, shown].slice(-MAX_MODIFIED) };
559  });
560}
561
562export function makeItem(abs, cwd, { id, via, agentId, at, size }) {
563  const rel = relOf(abs, cwd);
564  const name = basename(abs);
565  const dir = rel === null ? dirname(shortHome(abs)) : dirname(rel);
566  const folder = rel === null ? `${dir}/` : dir ? `${dir}/` : "./ (project root)";
567  return { id, key: keyOf(abs), abs, rel: rel ?? shortHome(abs), name, folder, via, agentId, at, size };
568}
569
570export function tagOf(item) {
571  const base = VIA_LABEL[item.via] ?? item.via;
572  return item.agentId ? `subagent ${base}` : base;
573}
574
575function tagColor(item) {
576  if (item.agentId) return "magenta";
577  if (item.via === "shell") return "yellow";
578  if (item.via === "other") return "gray";
579  return "green";
580}
581
582// New file paths in `after` that were not in `before`, not a Write in flight,
583// and not already in the tray.
584async function newPaths($, before, after, inflight) {
585  const { value: items = [] } = await $.state.get(itemsRef);
586  const known = new Set(items.map((x) => x.key));
587  const out = [];
588  for (const [key, abs] of after) {
589    if (before.has(key) || inflight.has(key) || known.has(key)) continue;
590    if (/(^|\/)\.DS_Store$/.test(abs)) continue;
591    const top = bundleOf(abs);
592    if (top !== abs && (known.has(keyOf(top)) || out.includes(top))) continue;
593    out.push(top);
594  }
595  return out.sort();
596}
597
598// A snapshot of the files under `cwd` a creation would add: git's untracked and
599// newly added files, or a bounded listing outside git. Map of key -> path, or
600// null when it cannot be taken cheaply (then nothing is guessed).
601async function snapshot($, cwd, roots) {
602  if (!cwd) return null;
603  try {
604    let root = roots.get(cwd);
605    if (root === undefined) {
606      const r = await $.process.run(["git", "-C", cwd, "rev-parse", "--show-toplevel"], { cwd, timeoutMs: 5000 });
607      root = r.exitCode === 0 ? r.stdout.trim() : "";
608      roots.set(cwd, root);
609    }
610    if (root) {
611      const r = await $.process.run(
612        ["git", "-C", cwd, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."],
613        { cwd, timeoutMs: 8000 },
614      );
615      if (r.exitCode !== 0 || r.isStdoutTruncated) return null;
616      return parsePorcelain(r.stdout, root);
617    }
618  } catch {
619    return null;
620  }
621  const home = (await $.env.get("HOME").catch(() => "")) ?? "";
622  if (cwd === "/" || (home && keyOf(cwd) === keyOf(home))) return null;
623  try {
624    const r = await $.process.run(
625      ["find", cwd, "-maxdepth", "6", "(", "-name", ".git", "-o", "-name", "node_modules", ")", "-prune", "-o", "-type", "f", "-print0"],
626      { cwd, timeoutMs: 5000 },
627    );
628    if (r.exitCode !== 0 || r.isStdoutTruncated) return null;
629    const out = new Map();
630    for (const p of r.stdout.split("\0")) if (p) out.set(keyOf(p), p);
631    return out;
632  } catch {
633    return null;
634  }
635}
636
637export function parsePorcelain(stdout, root) {
638  const out = new Map();
639  const parts = stdout.split("\0");
640  for (let i = 0; i < parts.length; i++) {
641    const entry = parts[i];
642    if (entry.length < 4) continue;
643    const xy = entry.slice(0, 2);
644    const p = entry.slice(3);
645    if (xy[0] === "R" || xy[0] === "C") i += 1; // the original path follows
646    if (xy === "??" || xy[0] === "A") {
647      const abs = `${root.replace(/\/$/, "")}/${p}`;
648      out.set(keyOf(abs), abs);
649    }
650  }
651  return out;
652}
653
654async function cwdOf($) {
655  const { value: m = EMPTY_META } = await $.state.get(metaRef);
656  if (m.cwd) return m.cwd;
657  try {
658    return await $.session.cwd();
659  } catch {
660    return "";
661  }
662}
663
664async function statOf($, abs, resolve = false) {
665  try {
666    return await $.fs.stat(abs, resolve ? { resolve: true } : undefined);
667  } catch {
668    return null;
669  }
670}
671
672async function listing($, items) {
673  const { value: m = EMPTY_META } = await $.state.get(metaRef);
674  if (items.length === 0) {
675    const edited = m.modified.length ? ` (${m.modified.length} existing file${m.modified.length === 1 ? "" : "s"} edited)` : "";
676    return `Output Tray: no files created yet this session${edited}.`;
677  }
678  const now = await $.clock.now();
679  const lines = [`Output Tray: ${items.length} file${items.length === 1 ? "" : "s"} created this session (newest first)`];
680  let i = 0;
681  for (const item of items) {
682    i += 1;
683    const st = await statOf($, item.abs);
684    const state = st ? `${sizeOf(st.size)}, ${ago(now - item.at)}` : "missing: deleted or moved";
685    lines.push(`${String(i).padStart(2)}. ${item.rel}  [${tagOf(item)}]  ${state}`);
686  }
687  if (m.modified.length) lines.push(`Also edited ${m.modified.length} existing file${m.modified.length === 1 ? "" : "s"}: ${m.modified.slice(-8).join(", ")}`);
688  lines.push("Open one with /tray open <n> or /tray reveal <n>.");
689  return lines.join("\n");
690}
691
692// ---- paths and formatting ---------------------------------------------------------------
693
694// A comparison key: NFC, macOS /private aliases folded, segments normalized.
695export function keyOf(path) {
696  let s = String(path).normalize("NFC");
697  s = s.replace(/^\/private(?=\/(tmp|var|etc)(\/|$))/, "");
698  const abs = s.startsWith("/");
699  const parts = [];
700  for (const seg of s.split("/")) {
701    if (seg === "" || seg === ".") continue;
702    if (seg === "..") parts.pop();
703    else parts.push(seg);
704  }
705  return (abs ? "/" : "") + parts.join("/");
706}
707
708export function relOf(abs, cwd) {
709  if (!cwd) return null;
710  const a = keyOf(abs);
711  const c = keyOf(cwd);
712  if (a === c) return "";
713  if (!a.startsWith(`${c}/`)) return null;
714  // keep the original spelling of the tail (only the prefix was folded)
715  return a.slice(c.length + 1);
716}
717
718function basename(p) {
719  const s = String(p).replace(/\/+$/, "");
720  return s.slice(s.lastIndexOf("/") + 1);
721}
722
723function dirname(p) {
724  const s = String(p).replace(/\/+$/, "");
725  const i = s.lastIndexOf("/");
726  return i < 0 ? "" : i === 0 ? "/" : s.slice(0, i);
727}
728
729function shortHome(p) {
730  return String(p).replace(/^\/Users\/[^/]+/, "~").replace(/^\/home\/[^/]+/, "~");
731}
732
733export function sizeOf(n) {
734  if (!Number.isFinite(n)) return "?";
735  if (n < 1024) return `${n} B`;
736  if (n < 1024 * 1024) return `${(n / 1024).toFixed(n < 10240 ? 1 : 0)} KB`;
737  return `${(n / 1024 / 1024).toFixed(1)} MB`;
738}
739
740export function ago(ms) {
741  const s = Math.max(0, Math.round(ms / 1000));
742  if (s < 10) return "just now";
743  if (s < 60) return `${s}s ago`;
744  const m = Math.round(s / 60);
745  if (m < 60) return `${m}m ago`;
746  const h = Math.round(m / 60);
747  if (h < 48) return `${h}h ago`;
748  return `${Math.round(h / 24)}d ago`;
749}
750
751function clip(s, n) {
752  const t = String(s);
753  return t.length <= n ? t : `${t.slice(0, Math.max(0, n - 1))}…`;
754}
755
types/index.d.ts 32 lines
1export type OutputTrayVia = "write" | "notebook" | "shell" | "other";
2
3export type OutputTrayItem = {
4  id: string;
5  key: string;
6  abs: string;
7  rel: string;
8  name: string;
9  folder: string;
10  via: OutputTrayVia;
11  agentId: string;
12  at: number;
13  size: number;
14};
15
16export type OutputTrayMeta = {
17  cwd: string;
18  seq: number;
19  selected: string;
20  modified: string[];
21  lastAction: string;
22};
23
24declare module "claude-code" {
25  interface PluginState {
26    "output-tray": {
27      items: OutputTrayItem[];
28      meta: OutputTrayMeta;
29    };
30  }
31}
32