SLOPSHOPPER

downloads-drop

Notices files that land in Downloads while you work (PDFs, 3MF models, images, notes) and offers them in a band above the prompt: Attach puts @"path" mentions…

newbandcommandtimer
v0.1.0no licenseupdated 2026-10-07joeldg/claude-mods/downloads-drop
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · downloads-drop
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ 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 › /downloads ⎿ downloads-drop: No pdf, md, txt, csv, json, png, jpg, jpeg, heic, webp, gif, 3mf, stl, obj, ply, glb, zip, mp4, mov files in ~ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.

Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).

git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods

dev-servers

A pane of your project's running dev servers, so you don't have to ask Claude to restart them.

  • /servers opens the Servers pane:
  • running servers whose working folder is in this repo: name, port, pid and uptime, with Restart, Stop and Log
  • known start commands that aren't running, with Start: package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile
  • a count of other listeners on the Mac
  • Only button presses start or stop anything:
  • Start runs the command detached, logging to ~/.claude/dev-servers/<project>/.
  • Stop sends SIGTERM. If the server ignores it, pressing again within 10s force-stops it.
  • Restart stops the server, waits for the port to free up, then starts it.
  • Before any signal, it checks the pid still runs the same command.
  • When a command fails with "address already in use", Claude gets a note (and you a toast) naming the holder, e.g. Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).
  • Status line: servers: :4000 :5173.
  • Makes no model calls: lsof and ps every 15s.

downloads-drop

Puts files you just downloaded into your prompt with one click.

  • Watches ~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].
  • Attach puts @"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files.
  • Waits until a file has finished downloading (skips partial downloads and files still growing), and ignores hidden and zero-byte files.
  • /downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.
  • Stacks with other mods' bands (repo-brief, standing-orders, secret-guard) instead of hiding them.
  • Makes no model calls.

Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).

effort-router

Sets effort per message, so you don't have to switch it by hand.

  • Git chores ("merged", "#219 merged", "commit and push", "push it", "open a PR", "close the issue") run at low effort and come back faster.
  • Deep asks (audit, review, plan, design, investigate, root cause, "why does…", "figure out") run at max.
  • Everything else, including approvals like "yes", "go ahead" and "continue" and anything that starts new work ("merged 219, go ahead with #214"), keeps your session's own effort.
  • Prompt cache: changing effort makes the whole conversation get cached again. So it never switches mid-turn, raises effort at once, and over a large, warm cache lowers it only after 2 routine turns in a row. In small contexts, or once the cache has lapsed, it switches right away.
  • Model guard: set avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included.
  • /route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.
  • Status line while a turn is routed: effort: low (routine).
  • Makes no model calls.

Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).

job-watch

A Jobs pane for long-running work: training runs, downloads, extractions.

  • Picks up background Bash tasks and detached nohup … > log & launches by itself.
  • /watch <log> [label] adds any other log file.
  • Shows progress, ETA and the last log line, and flags a job as stalled when its log goes quiet.
  • Shows free space on / and /Volumes/* (the NAS).
  • Toasts when a job finishes or stalls. The status line shows jobs: 2 running · 1 stalled.
  • /jobs opens the pane, /unwatch <label|done|all> removes jobs.
  • Makes no model calls: it reads logs with tail, checks processes with ps, and runs df.

Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.

machine-guard

Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.

  • Status line: RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%.
  • It refuses heavy jobs (training, inference, rendering, extraction, Blender, Docker, ffmpeg) when:
  • macOS reports critical memory pressure, or
  • the Mac is reserved with /busy.

When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.

  • /busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``
  • /guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.
  • Remote runs (modal run, ssh), tests (pytest) and installs are never treated as heavy.
  • Add your own heavy commands with the "Also heavy" setting (a regex), e.g. overnight_|nightly_run\.sh.

mod-monitor

Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.

  • Failures: any mod hook that throws, times out or rejects, read from the hook chain's results (next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast.
  • What each mod did: its toasts ("#219 merged → …", "Blocked: …"), status-line changes, the mod commands you used (never their arguments), and failed subprocesses (a burst of 5 in 10 minutes raises a toast). Git checks run outside a repository are logged as expected, not as failures. It also records model calls (the only usage the mods cost: /second-opinion, /recall ask) and file writes (folders only, never contents).
  • /mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it.
  • /mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read.
  • /mods failures [7d]: failures and failed subprocesses only.
  • Logs: ~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.
  • Makes no model calls and adds no measurable latency.
  • Error lines mods log themselves ($.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged.
  • Transcript-row hooks (secret-guard masks /secrets records there) are watched for failures and slow runs, but not counted per run.

Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).

modal-meter

Keeps an eye on Modal so idle GPU containers don't burn credits.

  • Status line while containers run: Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.
  • A toast when an app has had containers up longer than alertMinutes (30), repeated at most every 30 minutes.
  • /modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.
  • Shows today's spend and alerts on a budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.
  • Finds the CLI as modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.
  • Makes no model calls: only the Modal CLI, every 60s.

pr-autopilot

Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.

  • Watches your open PRs in the session's repo: it adopts them at session start, and picks up every gh pr create Claude runs. It polls gh pr view every 60s.
  • Status line: PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.
  • When a PR merges, it cleans up with plain local git, then toasts the outcome and suggests carrying on (Tab to accept):
  • git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.
  • Never with uncommitted changes, never --force, never other branches.
  • Deletes the local branch only if it points at exactly the commit GitHub merged, so nothing local is lost. It also leaves a branch checked out in another worktree alone.
  • A merge seen mid-turn is cleaned up when the turn ends, so git never races Claude.
  • When you mention failing CI ("#258 is failing", "CI failed, fix it"), your message goes to Claude with gh pr checks and the tail of the failed log attached, so you don't paste it.
  • /prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.
  • Makes no model calls: only gh and git, at about one GitHub API call per open PR per minute.

Settings:

  • pollSeconds (60)
  • attachCiLogs (on)
  • logLines (120)
  • deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.

It never closes issues; put "Closes #N" in PR bodies for that.

recall

Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.

  • What it searches: Claude Code sessions, Codex sessions, subagent and workflow runs, Claude's memory files, standing orders, second-opinion reviews, and your /remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.
  • What it keeps: prompts, answers, compaction summaries, session titles, commands, files touched, commits, PRs, issues, URLs, tasks and decisions (what you approved or ruled out). Read-only look-ups like grep and cat are kept but ranked low.
  • History survives cleanup: extracts stay searchable after Claude Code deletes old transcripts.
  • Claude searches it itself with four read-only tools, search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled.
  • Commands:
  • /recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>.
  • /recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it.
  • /recall timeline [7d|30d|90d] [all]
  • /recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]
  • /recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model.
  • /recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help.
  • /remember <fact>, /remember list, /remember forget <ref>.
  • Bands:
  • Once per session: Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].
  • When a prompt mentions #214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.
  • Query syntax: words must all match. OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.
  • Privacy:
  • The index lives at ~/.claude/recall/index.db, readable only by you, and never goes in a repo.
  • Secrets are masked before anything is stored: known token shapes, labelled values ("password: …"), the values of secret-named exports in ~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update.
  • Cost: no model calls except /recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes.
  • Requires macOS's /usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.

Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).

repo-brief

Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".

  • Gathers in the background at session start:
  • branch, ahead/behind and uncommitted files
  • the last 8 commits
  • open PRs with CI ✓/✗/…
  • issues labelled owner, todo, P0 or blocked
  • stale branches (merged, or upstream gone)
  • A one-line band above the prompt, e.g. main ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it.
  • Claude gets the same summary once, in its first message, so the prompt cache stays warm. It refreshes after compaction.
  • /brief re-gathers now and prints the full summary.
  • Makes no model calls: only git and gh. The band refreshes after a turn at most every 2 minutes.

Settings: focus labels, refresh minutes, and whether to brief Claude.

routine-watch

Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.

  • Knows a session is a routine from its scheduled-task prompt, and does nothing in your other sessions.
  • When a routine stops to wait for your OK on a permission prompt or an AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.
  • When a turn ends in an error, or the routine finishes, you get a notification: Routine daily-report finished after 23m · waited on you 2 times.
  • Phone push (optional): notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.
  • allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed.
  • /routine shows the routine's name, how long it has run, its waits, and the settings.
  • Makes no model calls.

second-opinion

A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.

  • /second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters.
  • Other forms:
  • /second-opinion commits 5
  • /second-opinion diff (uncommitted changes)
  • /second-opinion file docs/ADR-007.md
  • /second-opinion <question>: adds a question for Fable to answer first.
  • The command returns at once, and the status line shows second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.
  • Send to Claude attaches the review to your next prompt (once) and drafts "What do you agree with, and what would you act on?".
  • Reviews are saved in ~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.

Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).

standing-orders

Keeps your "always / never / don't / from now on" instructions alive across compaction.

  • When you write an instruction like "never open bambu with full spectrum files", a band asks: Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.
  • Project orders live in ~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session.
  • Claude gets them at the start of every conversation and again after each compaction or /clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message.
  • /orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).
  • Makes no model calls.

secret-guard

Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.

  • Catches known token shapes: AWS, GitHub, Anthropic, OpenAI, Slack, Google, Hugging Face, GitLab, npm, Stripe, private keys and bearer tokens.
  • Also catches labelled values ("password: …", "api key = …", "the wifi password is …") and the two-line "Access Key ID / Secret Access Key" paste.
  • Leaves alone $NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.
  • On a hit, the prompt isn't sent and goes back in the box. A band shows the secret masked (…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:
  • Save as env var appends export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME.
  • Send anyway lets exactly that text through once.
  • Edit dismisses the band.
  • The value is never shown in toasts, status, state or the transcript, and /secrets test <text> output is masked too.
  • /secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.
  • Makes no model calls.

Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).

slicer-handoff

Makes Claude's open commands hand 3D files to the right slicer.

  • Full-spectrum files go to Snapmaker Orca. Bambu Studio and OrcaSlicer can't open them. A file counts as full-spectrum when:
  • its name or folder matches full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], or
  • its 3MF names a Full Spectrum filament profile.

An open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.

  • Earlier windows close first. Before opening a file, it asks the running slicer to quit (a normal quit, never forced), so windows don't pile up. If one won't close, for example because it's waiting on a save prompt, it stops trying and tells Claude to leave it alone.
  • /slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.
  • Recognizes open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.
  • Makes no model calls.

Settings:

  • closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.
  • fullSpectrumPattern (the regex above)
  • checkContents (on)

The quit request goes out when Claude issues the command, before any permission prompt for it.

Loading

  • One session from a terminal: pass --plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot
  • Every session, including the desktop app: add to ~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``

Checking

Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.

claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)
Source 3 files
hooks/register.tsx 335 lines
1import { atom, read, update } from 'claude-code'
2import type { CommandRunResult, EngineInterface, Register } from 'claude-code'
3
4import type { DropFile, DropSeen } from '../types'
5import type { AttachOutcome } from './drop'
6import {
7  DEFAULT_EXTENSIONS,
8  DEFAULT_FOLDER,
9  USAGE,
10  attachedText,
11  bandParts,
12  byArrival,
13  displayFolder,
14  expandFolder,
15  folderName,
16  isBlankTree,
17  listText,
18  markKey,
19  markOf,
20  mentions,
21  newCandidates,
22  newest,
23  newestTime,
24  parseAction,
25  parseExtensions,
26  parsePicks,
27  pruneMarks,
28  sameFiles,
29  settle,
30  shortAge,
31  toDropFile,
32} from './drop'
33
34type Engine = EngineInterface
35
36const pending = atom({ plugin: 'downloads-drop', key: 'pending' } as const, [])
37const since = atom({ plugin: 'downloads-drop', key: 'since' } as const, null)
38const cleared = atom({ plugin: 'downloads-drop', key: 'cleared' } as const, [])
39const listed = atom({ plugin: 'downloads-drop', key: 'listed' } as const, [])
40
41type Config = { folder: string; extensions: string[]; pollMs: number; maxAgeMs: number }
42
43let config: Config = {
44  folder: DEFAULT_FOLDER,
45  extensions: parseExtensions(DEFAULT_EXTENSIONS),
46  pollMs: 5_000,
47  maxAgeMs: 120 * 60_000,
48}
49let timer: { cancel: () => void } | null = null
50let isPolling = false
51/** What the last check saw of each candidate, by name: a file is offered once a check sees it unchanged. */
52let seen: Map<string, DropSeen> = new Map()
53/** Bumped by every attach, dismiss and clear, so a check already under way does not put those files back. */
54let generation = 0
55/** The age the band last showed, so a check redraws it when that changes. */
56let ageLabel = ''
57let homeDir: string | undefined
58let hasHome = false
59let hasWarned = false
60
61const numberOr = (value: unknown, fallback: number): number => {
62  const n = Number(value)
63  return Number.isFinite(n) ? n : fallback
64}
65
66/** The watched folder as an absolute path; null when it names `~` and HOME is unset. */
67async function folderPath($: Engine): Promise<string | null> {
68  if (!hasHome) {
69    homeDir = await $.env.get('HOME').catch(() => undefined)
70    hasHome = true
71  }
72  return expandFolder(config.folder, homeDir)
73}
74
75/** The folder's entries, or null when it cannot be listed (said once, in the debug log). */
76async function listFolder($: Engine, dir: string | null) {
77  const entries = dir === null ? null : await $.fs.list(dir).catch(() => null)
78  if (entries === null && !hasWarned) {
79    hasWarned = true
80    $.ui.log(`downloads-drop: could not list ${dir ?? config.folder}`, { to: 'debug' })
81  }
82  return entries
83}
84
85/** One check of the folder: the files that have arrived and stopped growing become the band's. */
86async function poll($: Engine): Promise<void> {
87  if (isPolling) {
88    return
89  }
90  isPolling = true
91  try {
92    const started = generation
93    const dir = await folderPath($)
94    const entries = await listFolder($, dir)
95    if (dir === null || entries === null) {
96      return
97    }
98    const now = await $.clock.now()
99    const start = await read($, since)
100    if (start === null) {
101      await update($, since, () => now)
102      return
103    }
104    const marks = await read($, cleared)
105    const kept = pruneMarks(marks, start, now, config.maxAgeMs)
106    if (kept.length !== marks.length) {
107      await update($, cleared, list => pruneMarks(list, start, now, config.maxAgeMs))
108    }
109    const candidates = newCandidates(entries, {
110      extensions: config.extensions,
111      since: start,
112      now,
113      maxAgeMs: config.maxAgeMs,
114      cleared: kept,
115    })
116    const settled = settle(candidates, seen)
117    seen = settled.seen
118    if (started !== generation) {
119      return
120    }
121    const files = byArrival(settled.ready.map(entry => toDropFile(dir, entry)))
122    const label = files.length > 0 ? shortAge(now - newestTime(files)) : ''
123    if (!sameFiles(await read($, pending), files)) {
124      // An attach or dismiss while this check ran wins: its files stay off the band.
125      await update($, pending, list => (started === generation ? files : list))
126    } else if (files.length > 0 && label !== ageLabel) {
127      $.ui.invalidate('ui.render')
128    }
129    ageLabel = label
130  } finally {
131    isPolling = false
132  }
133}
134
135/**
136 * Starts the checks once per module load. A reload drops the timer, so the band's drawing and the
137 * command call this too; a session the desktop app or another SDK host runs starts with no surface,
138 * so its checks start when a surface first draws the band.
139 */
140function ensureWatching($: Engine): void {
141  if (timer !== null) {
142    return
143  }
144  timer = $.clock.every(config.pollMs, () => void poll($))
145}
146
147/** Takes files off the band for good (until they are modified again). */
148async function forget($: Engine, files: readonly DropFile[]): Promise<void> {
149  generation += 1
150  const keys = new Set(files.map(markKey))
151  await update($, cleared, marks => [...marks.filter(mark => !keys.has(markKey(mark))), ...files.map(markOf)])
152  await update($, pending, list => list.filter(file => !keys.has(markKey(file))))
153}
154
155/** Puts an @-mention of each file in the prompt box at the cursor; the files leave the band once it took them. */
156async function attachFiles($: Engine, files: readonly DropFile[]): Promise<AttachOutcome> {
157  const filled = await $.prompt.fill({ text: mentions(files), mode: 'insert' }).catch(() => null)
158  if (!filled?.isFilled) {
159    return filled?.refusal ?? 'refused'
160  }
161  await forget($, files)
162  return 'filled'
163}
164
165async function attachPending($: Engine): Promise<void> {
166  const files = await read($, pending)
167  if (files.length > 0) {
168    await attachFiles($, files)
169  }
170}
171
172async function dismissPending($: Engine): Promise<void> {
173  await forget($, await read($, pending))
174}
175
176/** The newest matching files, numbered as /downloads shows them; remembered for /downloads attach. */
177async function listFiles($: Engine, dir: string): Promise<DropFile[] | null> {
178  const entries = await listFolder($, dir)
179  if (entries === null) {
180    return null
181  }
182  const files = newest(entries, config.extensions).map(entry => toDropFile(dir, entry))
183  await update($, listed, () => files)
184  return files
185}
186
187async function listCommand($: Engine): Promise<CommandRunResult> {
188  const dir = await folderPath($)
189  const shown = dir === null ? config.folder : displayFolder(dir, homeDir)
190  const files = dir === null ? null : await listFiles($, dir)
191  if (files === null) {
192    return { text: `Could not read ${shown}.` }
193  }
194  if (files.length === 0) {
195    return { text: `No ${config.extensions.join(', ')} files in ${shown}.` }
196  }
197  const fresh = new Set((await read($, pending)).map(markKey))
198  return { text: listText(files, fresh, await $.clock.now(), shown) }
199}
200
201async function attachCommand($: Engine, picks: string): Promise<CommandRunResult> {
202  const dir = await folderPath($)
203  const shown = dir === null ? config.folder : displayFolder(dir, homeDir)
204  if (!picks.trim()) {
205    const files = await read($, pending)
206    if (files.length === 0) {
207      return { text: `Nothing new in ${shown} to attach. /downloads lists the newest files by number.` }
208    }
209    return { text: attachedText(files, await attachFiles($, files)) }
210  }
211  let files = await read($, listed)
212  if (files.length === 0 && dir !== null) {
213    files = (await listFiles($, dir)) ?? []
214  }
215  if (files.length === 0) {
216    return { text: `No ${config.extensions.join(', ')} files in ${shown} to attach.` }
217  }
218  const parsed = parsePicks(picks, files.length)
219  if ('error' in parsed) {
220    return { text: parsed.error }
221  }
222  const chosen = parsed.picks.flatMap(n => files[n - 1] ?? [])
223  const present: DropFile[] = []
224  const missing: string[] = []
225  for (const file of chosen) {
226    if (await $.fs.exists(file.path).catch(() => false)) {
227      present.push(file)
228    } else {
229      missing.push(file.name)
230    }
231  }
232  const gone = missing.length > 0 ? `\n(No longer in ${shown}: ${missing.join(', ')}; /downloads lists it afresh.)` : ''
233  if (present.length === 0) {
234    return { text: `Nothing to attach.${gone}` }
235  }
236  return { text: attachedText(present, await attachFiles($, present)) + gone }
237}
238
239/** Dismisses every file offered so far: from now on only files modified after this moment count. */
240async function clearAll($: Engine): Promise<CommandRunResult> {
241  generation += 1
242  const now = await $.clock.now()
243  const count = (await read($, pending)).length
244  seen = new Map()
245  ageLabel = ''
246  await update($, since, () => now)
247  await update($, cleared, () => [])
248  await update($, pending, () => [])
249  const what = count === 0 ? 'Nothing new to dismiss' : `Dismissed ${count} new ${count === 1 ? 'file' : 'files'}`
250  return { text: `${what}; only files that arrive from now on will be offered.` }
251}
252
253export const register: Register = (on, options) => {
254  config = {
255    folder: String(options.folder ?? DEFAULT_FOLDER).trim() || DEFAULT_FOLDER,
256    extensions: parseExtensions(String(options.extensions ?? DEFAULT_EXTENSIONS)),
257    pollMs: Math.min(3600, Math.max(1, numberOr(options.pollSeconds, 5))) * 1000,
258    maxAgeMs: Math.max(1, numberOr(options.maxAgeMinutes, 120)) * 60_000,
259  }
260  timer = null
261  isPolling = false
262  seen = new Map()
263  generation = 0
264  ageLabel = ''
265  homeDir = undefined
266  hasHome = false
267  hasWarned = false
268
269  on('session.start', async ($, e, next) => {
270    await $.command.register({
271      name: 'downloads',
272      description: 'List the newest files in Downloads, put some in the prompt (attach 1 3), or dismiss the new ones (clear)',
273      argumentHint: '[attach <n…> | clear]',
274    })
275    const now = await $.clock.now()
276    await update($, since, () => now)
277    if (e.isInteractive) {
278      ensureWatching($)
279    }
280    return next(e)
281  })
282
283  on('command.run', { command: 'downloads' }, async ($, e) => {
284    ensureWatching($)
285    const action = parseAction(e.args)
286    if (action.kind === 'attach') {
287      return attachCommand($, action.picks)
288    }
289    if (action.kind === 'clear') {
290      return clearAll($)
291    }
292    if (action.kind === 'help') {
293      return { text: USAGE }
294    }
295    return listCommand($)
296  })
297
298  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
299    ensureWatching($)
300    const files = await read($, pending)
301    if (e.props.hasSurvey || files.length === 0) {
302      return next(e)
303    }
304    const now = await $.clock.now()
305    const dir = await folderPath($)
306    const parts = bandParts(files, now, folderName(dir ?? config.folder))
307    const { Box, Text, Button } = $.ui.resolve(e)
308    const band = (
309      <Box flexDirection="row" gap={1}>
310        <Box flexShrink={1}>
311          <Text wrap="truncate-end">
312            <Text dimColor>{parts.lead}</Text>
313            {parts.names}
314            <Text dimColor>{parts.age}</Text>
315          </Text>
316        </Box>
317        <Box flexShrink={0} flexDirection="row" gap={1}>
318          <Button key="attach" label="Attach" hotkey="a" variant="primary" onPress={() => attachPending($)} />
319          <Button key="dismiss" label="Dismiss" hotkey="d" role="dismiss" onPress={() => dismissPending($)} />
320        </Box>
321      </Box>
322    )
323    // Another plugin's band beneath stays, under this one.
324    const below = await next(e)
325    return isBlankTree(below) ? (
326      band
327    ) : (
328      <Box flexDirection="column">
329        {band}
330        {below}
331      </Box>
332    )
333  })
334}
335
hooks/drop.ts 354 lines
1import type { FsEntry, RenderElement } from 'claude-code'
2
3import type { DropFile, DropMark, DropSeen } from '../types'
4
5/** Suffixes browsers and download tools give a file while it is still being written. */
6export const PARTIAL_SUFFIXES = ['.crdownload', '.download', '.part', '.tmp', '.opdownload'] as const
7
8export const DEFAULT_FOLDER = '~/Downloads'
9export const DEFAULT_EXTENSIONS =
10  'pdf,md,txt,csv,json,png,jpg,jpeg,heic,webp,gif,3mf,stl,obj,ply,glb,zip,mp4,mov'
11
12/** Names the band spells out before `+N more`. */
13export const BAND_NAMES = 3
14/** Files `/downloads` lists. */
15export const LIST_LIMIT = 10
16/** Files attached or dismissed that are remembered at most. */
17const MARK_LIMIT = 500
18/** A longer name is shortened in the middle, keeping its end and extension. */
19const NAME_MAX = 40
20
21export const USAGE = [
22  'Usage:',
23  '  /downloads              list the newest files, numbered',
24  '  /downloads attach 1 3   put those files in the prompt as @"path" mentions (ranges like 2-4 work)',
25  '  /downloads attach       put the new files the band shows in the prompt',
26  '  /downloads clear        dismiss every new file; only files that arrive from now on are offered',
27].join('\n')
28
29/** `pdf, .MD;3mf` → `['pdf', 'md', '3mf']`; `*` stands for every file. */
30export function parseExtensions(text: string): string[] {
31  const list = text
32    .split(/[\s,;]+/)
33    .map(ext => ext.trim().replace(/^\*?\./, '').toLowerCase())
34    .filter(Boolean)
35  return [...new Set(list)]
36}
37
38/** The extension after the last dot, lowercased; '' when there is none (or the name is only a dotfile). */
39export function extensionOf(name: string): string {
40  const dot = name.lastIndexOf('.')
41  return dot > 0 && dot < name.length - 1 ? name.slice(dot + 1).toLowerCase() : ''
42}
43
44export const isHiddenName = (name: string): boolean => name.startsWith('.')
45
46export function isPartialName(name: string): boolean {
47  const lower = name.toLowerCase()
48  return PARTIAL_SUFFIXES.some(suffix => lower.endsWith(suffix))
49}
50
51/** Whether the name's extension is wanted; an empty list or `*` wants every file. */
52export function hasExtension(name: string, extensions: readonly string[]): boolean {
53  return extensions.length === 0 || extensions.includes('*') || extensions.includes(extensionOf(name))
54}
55
56/** A regular file of a wanted type that is neither hidden nor a partial download. */
57export function isMatching(entry: FsEntry, extensions: readonly string[]): boolean {
58  return (
59    entry.kind === 'file' &&
60    !isHiddenName(entry.name) &&
61    !isPartialName(entry.name) &&
62    hasExtension(entry.name, extensions)
63  )
64}
65
66export const markKey = (file: { name: string; mtimeMs: number }): string => `${file.mtimeMs}:${file.name}`
67
68export const markOf = (file: DropFile): DropMark => ({ name: file.name, mtimeMs: file.mtimeMs })
69
70export type NewRules = {
71  extensions: readonly string[]
72  /** Only files modified after this count. */
73  since: number
74  now: number
75  /** A file modified longer ago than this never counts. */
76  maxAgeMs: number
77  /** Files already attached or dismissed. */
78  cleared: readonly DropMark[]
79}
80
81/**
82 * The entries that may be new downloads: matching, non-empty, modified after `since` and within
83 * `maxAgeMs`, not attached or dismissed already, and with no partial download of the same name
84 * beside them (Firefox writes an empty `name` next to `name.part`).
85 */
86export function newCandidates(entries: readonly FsEntry[], rules: NewRules): FsEntry[] {
87  const names = new Set(entries.map(entry => entry.name))
88  const cleared = new Set(rules.cleared.map(markKey))
89  return entries.filter(
90    entry =>
91      isMatching(entry, rules.extensions) &&
92      entry.size > 0 &&
93      entry.mtimeMs > rules.since &&
94      rules.now - entry.mtimeMs <= rules.maxAgeMs &&
95      !PARTIAL_SUFFIXES.some(suffix => names.has(entry.name + suffix)) &&
96      !cleared.has(markKey(entry)),
97  )
98}
99
100/**
101 * Splits this check's candidates into the ones whose size and time the previous check already saw
102 * (ready) and the rest (still settling, or still downloading); `seen` is what the next check compares to.
103 */
104export function settle(
105  candidates: readonly FsEntry[],
106  previous: ReadonlyMap<string, DropSeen>,
107): { ready: FsEntry[]; seen: Map<string, DropSeen> } {
108  const seen = new Map<string, DropSeen>()
109  const ready: FsEntry[] = []
110  for (const entry of candidates) {
111    const before = previous.get(entry.name)
112    if (before !== undefined && before.size === entry.size && before.mtimeMs === entry.mtimeMs) {
113      ready.push(entry)
114    }
115    seen.set(entry.name, { size: entry.size, mtimeMs: entry.mtimeMs })
116  }
117  return { ready, seen }
118}
119
120/** Marks still able to hide a file: modified after `since` and within `maxAgeMs`, the newest kept. */
121export function pruneMarks(marks: readonly DropMark[], since: number, now: number, maxAgeMs: number): DropMark[] {
122  return marks.filter(mark => mark.mtimeMs > since && now - mark.mtimeMs <= maxAgeMs).slice(-MARK_LIMIT)
123}
124
125/** The folder setting as an absolute path: `~` is `home`, a relative path is under `home`; null without one. */
126export function expandFolder(folder: string, home: string | undefined): string | null {
127  const raw = folder.trim() || DEFAULT_FOLDER
128  const base = home?.replace(/\/+$/, '')
129  let path: string
130  if (raw.startsWith('/')) {
131    path = raw
132  } else if (!base) {
133    return null
134  } else if (raw === '~' || raw.startsWith('~/')) {
135    path = base + raw.slice(1)
136  } else {
137    path = `${base}/${raw}`
138  }
139  return path.length > 1 ? path.replace(/\/+$/, '') : path
140}
141
142/** `/Users/me/Downloads` → `~/Downloads` when it lies under `home`. */
143export function displayFolder(dir: string, home: string | undefined): string {
144  const base = home?.replace(/\/+$/, '')
145  if (base && (dir === base || dir.startsWith(`${base}/`))) {
146    return `~${dir.slice(base.length)}`
147  }
148  return dir
149}
150
151/** The folder's own name, as the band says it: `Downloads`. */
152export function folderName(dir: string): string {
153  return dir.replace(/\/+$/, '').split('/').pop() || dir
154}
155
156export function joinPath(dir: string, name: string): string {
157  return `${dir.replace(/\/+$/, '')}/${name}`
158}
159
160export function toDropFile(dir: string, entry: FsEntry): DropFile {
161  return { name: entry.name, path: joinPath(dir, entry.name), size: entry.size, mtimeMs: entry.mtimeMs }
162}
163
164/** Oldest first, the order they arrived in. */
165export function byArrival(files: readonly DropFile[]): DropFile[] {
166  return [...files].sort((a, b) => a.mtimeMs - b.mtimeMs || a.name.localeCompare(b.name))
167}
168
169/** The `limit` most recently modified matching files, newest first. */
170export function newest(entries: readonly FsEntry[], extensions: readonly string[], limit = LIST_LIMIT): FsEntry[] {
171  return entries
172    .filter(entry => isMatching(entry, extensions))
173    .sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name))
174    .slice(0, limit)
175}
176
177export function sameFiles(a: readonly DropFile[], b: readonly DropFile[]): boolean {
178  return (
179    a.length === b.length &&
180    a.every((file, i) => {
181      const other = b[i]
182      return other !== undefined && file.path === other.path && file.size === other.size && file.mtimeMs === other.mtimeMs
183    })
184  )
185}
186
187export function newestTime(files: readonly DropFile[]): number {
188  return files.reduce((latest, file) => Math.max(latest, file.mtimeMs), 0)
189}
190
191/** `just now`, `2m ago`, `5h ago`, `3d ago`. */
192export function shortAge(ms: number): string {
193  const seconds = Math.max(0, Math.floor(ms / 1000))
194  if (seconds < 60) {
195    return 'just now'
196  }
197  const minutes = Math.floor(seconds / 60)
198  if (minutes < 60) {
199    return `${minutes}m ago`
200  }
201  const hours = Math.floor(minutes / 60)
202  if (hours < 48) {
203    return `${hours}h ago`
204  }
205  return `${Math.floor(hours / 24)}d ago`
206}
207
208/** Bytes as Finder counts them (1000 to a KB): `512 B`, `8.1 KB`, `812 KB`, `2.4 MB`. */
209export function formatSize(bytes: number): string {
210  if (bytes < 1000) {
211    return `${bytes} B`
212  }
213  const units = ['KB', 'MB', 'GB', 'TB']
214  let value = bytes / 1000
215  let unit = 0
216  while (value >= 999.5 && unit < units.length - 1) {
217    value /= 1000
218    unit += 1
219  }
220  return `${value < 9.95 ? value.toFixed(1) : Math.round(value)} ${units[unit]}`
221}
222
223/** A name of at most `max` characters, shortened in the middle so the extension stays. */
224export function shortName(name: string, max = NAME_MAX): string {
225  if (name.length <= max) {
226    return name
227  }
228  const tail = 10
229  return `${name.slice(0, max - tail - 1)}…${name.slice(-tail)}`
230}
231
232/** `a.pdf, b.pdf, c.pdf +2 more`. */
233export function bandNames(files: readonly DropFile[]): string {
234  const names = files
235    .slice(0, BAND_NAMES)
236    .map(file => shortName(file.name))
237    .join(', ')
238  const more = files.length - BAND_NAMES
239  return more > 0 ? `${names} +${more} more` : names
240}
241
242export type BandParts = { lead: string; names: string; age: string }
243
244/** The band's line in its three spans: `New in Downloads: `, the names, ` · 2m ago` (the newest file's age). */
245export function bandParts(files: readonly DropFile[], now: number, folder: string): BandParts {
246  return { lead: `New in ${folder}: `, names: bandNames(files), age: ` · ${shortAge(now - newestTime(files))}` }
247}
248
249export function bandLine(files: readonly DropFile[], now: number, folder: string): string {
250  const parts = bandParts(files, now, folder)
251  return parts.lead + parts.names + parts.age
252}
253
254/** `@"/Users/me/Downloads/a b.pdf" `: quoted, so a path with spaces stays one mention. */
255export const mention = (path: string): string => `@"${path}" `
256
257export function mentions(files: readonly DropFile[]): string {
258  return files.map(file => mention(file.path)).join('')
259}
260
261/** `/downloads`' answer: the files numbered newest first, the new ones marked. */
262export function listText(files: readonly DropFile[], fresh: ReadonlySet<string>, now: number, display: string): string {
263  const width = String(files.length).length
264  const rows = files.map((file, i) => {
265    const mark = fresh.has(markKey(file)) ? ' · new' : ''
266    return `${String(i + 1).padStart(width + 2)}. ${file.name} · ${formatSize(file.size)} · ${shortAge(now - file.mtimeMs)}${mark}`
267  })
268  return [`Newest in ${display} (/downloads attach 1 3 puts files in the prompt):`, ...rows].join('\n')
269}
270
271/** `1 3`, `1,3`, `2-4` → the numbers, in order and once each; an error when one is not on the list. */
272export function parsePicks(text: string, count: number): { picks: number[] } | { error: string } {
273  const tokens = text.split(/[\s,]+/).filter(Boolean)
274  if (tokens.length === 0) {
275    return { error: 'Name the files by number, as /downloads lists them: /downloads attach 1 3' }
276  }
277  const picks: number[] = []
278  for (const token of tokens) {
279    const range = /^(\d+)(?:-(\d+))?$/.exec(token)
280    if (range === null) {
281      return { error: `"${token}" is not a file number.` }
282    }
283    const from = Number(range[1])
284    const to = range[2] === undefined ? from : Number(range[2])
285    if (to < from) {
286      return { error: `"${token}" is not a range from low to high.` }
287    }
288    for (let n = from; n <= to; n++) {
289      if (n < 1 || n > count) {
290        return { error: `There is no file ${n}; the list has ${count}.` }
291      }
292      if (!picks.includes(n)) {
293        picks.push(n)
294      }
295    }
296  }
297  return { picks }
298}
299
300export type DownloadsAction =
301  | { kind: 'list' }
302  | { kind: 'attach'; picks: string }
303  | { kind: 'clear' }
304  | { kind: 'help' }
305
306/** What `/downloads <args>` asks for; bare numbers (`/downloads 2`) attach. */
307export function parseAction(args: string): DownloadsAction {
308  const trimmed = args.trim()
309  const [word = '', ...rest] = trimmed.split(/\s+/)
310  const verb = word.toLowerCase()
311  if (verb === '' || verb === 'list' || verb === 'ls') {
312    return { kind: 'list' }
313  }
314  if (verb === 'attach' || verb === 'add') {
315    return { kind: 'attach', picks: rest.join(' ') }
316  }
317  if (verb === 'clear' || verb === 'dismiss') {
318    return { kind: 'clear' }
319  }
320  if (/^\d/.test(verb)) {
321    return { kind: 'attach', picks: trimmed }
322  }
323  return { kind: 'help' }
324}
325
326/**
327 * How a fill went: the box took the text, a dialog held the keys, the session has no prompt box
328 * (a headless run), or a hook kept it out.
329 */
330export type AttachOutcome = 'filled' | 'dialog' | 'no_composer' | 'refused'
331
332const NOT_TAKEN: Record<Exclude<AttachOutcome, 'filled'>, string> = {
333  dialog: 'A dialog has the keys, so the files were not put in the prompt.',
334  no_composer: 'There is no prompt box in this session, so the files were not put in it.',
335  refused: 'The prompt box did not take the files.',
336}
337
338/** `/downloads attach`'s answer: what went into the prompt, or the mentions to paste by hand. */
339export function attachedText(files: readonly DropFile[], outcome: AttachOutcome): string {
340  if (outcome !== 'filled') {
341    return `${NOT_TAKEN[outcome]} Paste these instead:\n${mentions(files).trimEnd()}`
342  }
343  const names = files.map(file => file.name).join(', ')
344  return `Put ${files.length} ${files.length === 1 ? 'file' : 'files'} in the prompt: ${names}`
345}
346
347/** True for what the engine draws when nobody else draws the band, or an empty Box. */
348export function isBlankTree(tree: RenderElement): boolean {
349  if (tree.type === 'engine') {
350    return true
351  }
352  return tree.type === 'Box' && (tree.children ?? []).length === 0
353}
354
types/index.d.ts 32 lines
1/** A file in the watched folder, as the band and /downloads show it. */
2export type DropFile = {
3  name: string
4  /** The absolute path the @-mention names. */
5  path: string
6  /** Bytes. */
7  size: number
8  /** Last modification, milliseconds since the epoch. */
9  mtimeMs: number
10}
11
12/** A file already attached or dismissed: it is offered again only when its modification time changes. */
13export type DropMark = { name: string; mtimeMs: number }
14
15/** What the last check saw of a candidate: it is ready once the next check sees the same size and time. */
16export type DropSeen = { size: number; mtimeMs: number }
17
18declare module 'claude-code' {
19  interface PluginState {
20    'downloads-drop': {
21      /** The new files the band offers, oldest first. */
22      pending: DropFile[]
23      /** Only files modified after this count as new: the session's start, or the last /downloads clear. */
24      since: number | null
25      /** Files attached or dismissed since then. */
26      cleared: DropMark[]
27      /** The last /downloads listing, whose numbers /downloads attach takes. */
28      listed: DropFile[]
29    }
30  }
31}
32