SLOPSHOPPER

modpack

The claude-mods modpack: statusline, smart-compact, delegate, lsp-plus, cells, eta and seek, installed together and built to work together, and route, off…

newpanecommandprocessnetworktimer
v0.6.0MITupdated 2026-10-16HarzerHeribert/claude-modpack/modpack
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · modpack
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ statusline not installed │ ⏺ Read(src/auth.ts) │ ┃ │ one line under the prompt: model, │ ⎿ Read 6 lines │ ┃ │ context, plan use │ ⏺ Update(src/auth.ts) │ ┃ │ /plugin install statusline --marketplace │ ⎿ Added 2 lines, removed 1 line │ ┃ │ HarzerHeribert/claude-modpack │ ⏺ Bash(bun test) │ ┃ ╰──────────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ smart-compact not installed │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ keeps the prompt cache warm or compacts │ │ ┃ │ before it expires │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ /plugin install smart-compact │ │ ┃ │ --marketplace │ › /update │ ┃ │ HarzerHeribert/claude-modpack │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ delegate not installed │ │ ┃ │ hands a task to a cheaper worker model │ │ ┃ │ and checks its work │ │ ┃ │ /plugin install delegate --marketplace │ │ ┃ │ HarzerHeribert/claude-modpack │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ lsp-plus not installed │ │ ┃ │ the language server's answers for greps, │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
╭──────────────────────────────────────────────────────────╮ │ statusline not installed │ │ one line under the prompt: model, context, plan use │ │ /plugin install statusline --marketplace │ │ HarzerHeribert/claude-modpack │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ smart-compact not installed │ │ keeps the prompt cache warm or compacts before it │ │ expires │ │ /plugin install smart-compact --marketplace │ │ HarzerHeribert/claude-modpack │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ delegate not installed │ │ hands a task to a cheaper worker model and checks its │ │ work │ │ /plugin install delegate --marketplace │ │ HarzerHeribert/claude-modpack │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ lsp-plus not installed │ │ the language server's answers for greps, docs in │ │ references │ │ /plugin install lsp-plus --marketplace │ │ HarzerHeribert/claude-modpack │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ cells not installed │ │ several edits and their check in one step │ │ /plugin install cells --marketplace │ │ HarzerHeribert/claude-modpack │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ eta not installed │ │ a percentage and finish time for long-running jobs │ │ /plugin install eta --marketplace │ │ HarzerHeribert/claude-modpack │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ seek not installed │ │ has Claude look around your code in few, lean steps │ │ /plugin install seek --marketplace │ │ HarzerHeribert/claude-modpack │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ route not installed │ │ sends feature and test work to delegate's worker (Jev or │ │ ScaleDown; off until you turn it on) │ │ /plugin install route --marketplace │ │ HarzerHeribert/claude-modpack │ ╰──────────────────────────────────────────────────────────╯
README

<img alt="claude-modpack: eight Claude Code mods, each measured on real tasks before it shipped, built to work together. statusline, smart-compact, delegate, lsp-plus, cells, eta, seek, route." src="assets/banner-light.svg" width="880">

<h3>Install in Claude Code</h3>

/plugin install modpack --marketplace HarzerHeribert/claude-modpack

Answer y, then run /modpack:setup to set everything up step by step.<br> Prefer doing it by hand? SETUP.md has every step.

Release License: MIT Claude Code 2.1.296 730 graded runs, all correct 8 mods Lab notes

Claude Code with fewer wasted steps, and a clear view of what it's doing.

Eight free add-ons that work on their own. One command installs them, and there is nothing new to learn.<br> Every number on this page was measured, including where it doesn't help.

Install · What you get · Honest numbers · The site: ideas and evidence · Lab notes · Questions

<img alt="Animated: the cost per job with and without the pack, as bars that grow: in a long session $0.44 against $0.29 (−33%; measured twice, −26% one day and −40% the next), bigger jobs in a fresh session $0.55 against $0.51 (−8%), quick questions in a fresh session $0.23 against $0.24 (+2%, about even: +5% one day, −4% the next); then four numbers: −33% cost per job in a long session, −21% steps for the same work, −22% cost of a feature a cheaper model builds from your plan, 224 of 224 results correct." src="assets/numbers.gif" width="880">

Why it saves

Every time Claude takes a step (reads a file, makes an edit, runs the tests), it reads your whole conversation again. That re-reading is most of what a session costs, and it grows the longer you work. So the cheapest step is the one Claude never has to take.

The pack takes out steps that need no thinking. Several edits and their test run as one step. A search for a name in your code comes back with the exact answer. And when you approve a plan, a cheaper model does the building while Claude checks the result. The work comes out the same; Claude just gets there in fewer round trips.

What you get

Always know where your plan stands

<img alt="A real Claude Code session: Claude starts the stress tests in the background; above the prompt a row shows them with a bar, a percentage and a finish time, climbing from 8% to 100%; under the prompt the status line shows the model, the context, the plan's 5-hour and weekly use and a green ready dot." src="assets/demo-band.gif" width="880">

One line under the prompt: the model, how full the conversation is, and how much of your plan's 5-hour and weekly limits you have used, with when each one resets. Long jobs get a progress bar and a finish time above the prompt. And when you step away, your session is kept ready, or tidied up before it goes cold, whichever costs less; the line tells you which.

These are the statusline, eta and smart-compact mods.

Several changes in one step, and you can still undo them

<img alt="A real Claude Code session: asked to add two docstrings and run the whole test suite, Claude runs one cell; an orange card shows the read, the two edits, the diff and the test run live, and the tests pass: 1990 passed." src="assets/demo-cells.gif" width="880">

Claude makes several edits and runs the tests as one step, and you watch each part happen in a live card. Every edit still goes through Claude Code's own edit tool: you see each change, and /rewind undoes it.

That's the cells mod.

A cheaper model builds what you approved

<img alt="A real Claude Code session: in plan mode Claude plans a new click.Duration parameter type; the plan is approved; Claude hands it to a Sonnet worker, whose orange card shows its steps live with a row above the prompt (hand-off 1, sonnet medium, requests and cost); then Claude checks the diff and reports: the new type works and the full suite passes." src="assets/demo-delegate.gif" width="880">

When you approve a plan, Claude hands the building to Sonnet, a cheaper model, and checks its work before reporting back. In our tests that made a feature 22% cheaper and new tests 26% cheaper, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 27% and 33%). You can watch the helper work in its card, and /delegate shows every step it took.

That's the delegate mod.

Feature work goes to the cheaper model by itself (opt-in)

A small, fast classifier reads each prompt you send. Only when it is feature or test work is Claude told to hand it straight to delegate's worker: the worker reads the code and builds, delegate checks the result itself, and Claude reports after two steps of its own. On a TypeScript feature that made the job 51% cheaper and 42% faster than the pack alone, with every result correct (corrected 16 Oct: worker costs were undercounted; it was 57%); everything else stays with Claude. The classifier is an outside service (TypeSafe's Jev or ScaleDown), so this is off until you turn it on and give it a key: from then on, the start of each prompt goes to it, in the US, to be classified.

That's the route mod.

Straight answers about your code

When Claude searches your code for a name, it gets the exact list of places that use it, from the same engine your editor uses, instead of reading through search results. Renames and "who calls this?" questions take fewer steps. New in 0.5.0: Go, Kotlin and Ruby code, shell scripts and Terraform. Run /lsp-plus:setup once in your project to turn it on.

That's the lsp-plus mod.

Fewer, leaner steps when Claude looks around

Before Claude changes anything, it looks around your code, and everything it reads stays in the conversation, read again with every later step. Beside the first prompt you send in a session, and again as the session grows, the pack passes on your preference to look around in few, lean steps: the searches it can already name in one go, the lines that matter rather than whole files, nothing read twice. Against the pack without it, that made jobs 12% cheaper in a fresh session, 16% on a TypeScript library and 10 to 16% in long sessions, at the same quality. It runs on your machine and sends nothing anywhere.

That's the seek mod.

You stay in control

<img alt="A real Claude Code session: /mods lists the pack's mods (recorded with the six of 0.4.0, before route and seek) and opens a pane of cards; the status line is turned off and disappears from under the prompt, then turned on again." src="assets/demo-mods.gif" width="880">

/mods lists the eight and turns any of them on or off, right away, with a click or by typing, such as /mods statusline off. When something can't fully work on your computer, the pack tells you once, with the command that fixes it. When another plugin's permission or notification hook hangs (Warp's does outside the Warp terminal), the pack stops waiting for it after 10 seconds, so Claude Code can't freeze, and tells you which one it was. It keeps itself up to date (or only lets you know, if you prefer).

That's the modpack plugin, which installs the other eight.

Install

In Claude Code, type:

/plugin install modpack --marketplace HarzerHeribert/claude-modpack

Answer y to add the marketplace, then pick where to install it. That installs all eight. Then, in the project you work in:

/modpack:setup

It checks your machine, asks once what you want on (route stays off unless you say yes), sets each mod up in order, including the language servers for the languages your project uses, and checks that each one works. Done. /mods shows what's on.

By hand instead: SETUP.md has every step.

Each mod installs on its own the same way, for example /plugin install smart-compact --marketplace HarzerHeribert/claude-modpack. lsp-plus, delegate and cells build on Claude Code's LSP tool, which needs a language server for each language: Language servers lists them.

Honest numbers

We measured the pack on Claude Opus 5.5 doing seven real jobs in an open-source project (click), from a one-line lookup to building a feature, on two days, 13 and 15 October. Every result was checked by hidden tests. The same jobs ran with the pack and without it, and we compared what they cost at API prices.

Cost per jobWithoutWith the pack
In a long session (Claude has already read the whole project)$0.44$0.29−33%
Bigger jobs in a long session (a feature, tests, a rename)$0.91$0.57−37%
In a fresh session$0.37$0.35−4%
Bigger jobs in a fresh session$0.55$0.51−8%, and 15% faster
Quick questions in a fresh session$0.23$0.24+2%, about even
  • Where it doesn't help: quick questions in a brand-new session. The pack's tools come along with every step, and a three-step lookup has little to save: 5% more one day, 4% less the next, about even.
  • Writing tests: the pack alone saves nothing there (−2%, 12 runs each way): every step waits on the last test run. route hands it to the worker instead (−36% to −46%, below).
  • Nothing got worse: all 224 runs behind this table produced a correct result, with the pack and without.
  • The 95% ranges: long session −39% to −27%; fresh −7% to −1%; bigger jobs fresh −12% to −3%; quick questions −1% to +6%. The long session is two runs per job each day, and the days differ: −26% on one, −40% on the other, mostly from the feature (plain needed 12 requests one day, 16 the next).
  • The same on other code: on a TypeScript library (chrono) the pack cost 7.6% less (−12% to −3%) and took fewer steps on all eight jobs. On 30 real bug reports from SWE-bench Verified it fixed 30 against 29, with 23% fewer steps; the cost, −12%, is not certain at one attempt per bug (−33% to +12%).
  • A plan handed to a cheaper model: −22% on a feature, −26% on tests, against Opus doing it alone with the pack (16 runs, all correct).
  • With route turned on: a further −14% on click's eight jobs (route 0.1). With 0.6.0's verified hand-offs, against the pack in the same batch on TypeScript: a feature −51% and 42% faster, writing tests −36%; Claude itself takes 2 steps instead of 6 to 11 (24 runs, all correct).
  • Corrected 16 Oct: worker costs were undercounted. A worker's transcript records its output as each response began, and our bench counted it from there, so every number with a hand-off in it came out too good. Corrected from Claude Code's own totals: a planned feature −22% (was −27%), tests −26% (was −33%), route on click −14% (was −17%), the TypeScript feature −51% (was −57%). Every saving is still a saving, a smaller one. The table above has no hand-offs and did not change. Each claim, old and new: CORRECTION-1016.md.
  • With seek (new in 0.6.0, measured against the pack rather than plain Claude Code, so not in the table above): −12% fresh on click, −16% on TypeScript, −10% to −16% in long sessions (seek, below).
  • Two days, two versions: the 13 October runs used the pack as of 0.3.0 and Claude Code 2.1.296, the 15 October runs 0.4.0 and 2.1.299. What changed in the pack between them (on/off switches, setup, messages shown when a worker is refused) never reached the model in these runs: its first request was 26.7k tokens plain and 30.6k with the pack on both days. The second day's plain runs were done twice, because the first try had the pack installed by mistake: lab notes 8 tells how we caught it.

<img alt="Cost per task, plain Claude Code against the modpack, in a fresh session and in a session already holding 216k tokens." src="assets/cost-light.svg" width="880">

<img alt="Model requests per task, plain Claude Code against the modpack: 3.2 against 5.6 in a session already holding 216k tokens." src="assets/requests-light.svg" width="880">

<img alt="Cost per task, Opus alone against a plan handed to a worker: feature work 22% less, tests 26% less." src="assets/delegate-light.svg" width="880">

How we measured, every run's record, and the scripts to run it all again are in benchmarks/, starting with SUITE.md. What we tried that did not make it, a 10,000-star code graph among it, is in the lab notes (as Markdown). Not measured: what smart-compact saves you while you're away (worked out from prices, not tested) and Windows.

Questions

Will Claude's work get worse? No. In every run we graded, 730 in all, the result was correct with the pack and without, and every one was graded again for this release. The pack changes how many steps Claude takes to get there, and in our tests the results came out the same.

Is it safe? It's open source (MIT), so every line is here to read. Code that Claude runs inside a cell runs in a sandbox with no network access. Edits go through Claude Code's own edit tool, so your usual permission prompts and /rewind still apply. The pack sends nothing anywhere except a once-a-day check on GitHub for a new release, which you can turn off, and, only if you turn route on, the start of each prompt to the classifier you chose (TypeSafe or ScaleDown).

Does the pack add anything to my prompts? Your prompt itself is never changed. Beside it, Claude can get a short note in your name: seek's setting to look around in few, lean steps (with the first prompt of a session, and again after a compaction), and, only if you turn route on, the order to hand feature or test work to the worker; delegate gives the same order when you approve a plan of 20 lines or more. A subagent Claude starts gets cells' and seek's settings at the end of its task, since your prompts never reach it. /mods seek off and /mods route off stop theirs; subagents: false in cells or seek stops theirs for subagents.

I'm on a Pro or Max plan. Does this help me? You don't pay per job, but every step counts against your 5-hour and weekly limits. We measured cost at API prices, not plan usage, so we can't promise a number. Fewer, lighter steps should leave more of your limits, and the status line shows exactly where you stand.

Does it work in the desktop app? Yes, cards, progress and the status line included. One limit: in auto mode the desktop app doesn't let an add-on start a helper model, so plans aren't handed off there (turn route off there too); in manual mode, or in the terminal, they are. Windows hasn't been tested yet.

How do I turn it off? /mods switches each part off or on, right away. /plugin removes them.

Were any of these numbers wrong? Yes, the hand-off ones. On 16 October we found that our bench had counted a worker's output only as each of its responses began, so every saving that involved a worker came out too large: a planned feature was −27% and is −22%. They are corrected here, the old and new number side by side in CORRECTION-1016.md. The pack-against-plain numbers had no workers and stand.

What does it cost? Nothing. It's free and open source.

The mods in depth

One line under the prompt, drawn in Claude Code's own hint row so the mode indicator and the status share it:

⏵⏵ auto mode on       Opus 5.5 xhigh   412k ━━━━━━──────   5h 34% ↺2h14   7d 12% ↺3d   ● ready · cold in 44m
  • Its own segments: the model and its effort, the context against the auto-compact point (a highlight sweeps along it while Claude works), and how much of the plan's 5-hour and 7-day windows is used, with when each resets: quiet under half, plain past it, amber from 80%.
  • Other mods' segments: a mod calls $.statusline.set({ id, priority, parts, short?, side? }) and lists statusline among its dependencies. A part is text, a countdown ({ until, prefix?, warnMs? }, counted by the line) or a spinner ({ spin: true }), so a mod sends a segment when it changes, not every second. $.statusline.remove({ id }) takes it off.
  • Narrow terminals drop the least important first: the model, then the windows' resets, the windows, the gauge, and another mod's countdown before its own words.
  • Published pages: a pill on the left lists the pages this session published with the Artifact tool.
  • Phone and IDE: where no line is drawn, the same line is the session's status text, in a session the phone or IDE hosts. A terminal session watched over Remote Control (/rc) from the desktop app, the web or the phone gets no mod drawing and no status text as of Claude Code 2.1.296.
OptionDefault
enabledtruefalse turns the mod off; /mods does it for all of the pack.
placementlinetail: appended dim to Claude Code's hint. band: above the prompt. off.
limitsalwayshalf: a plan window only once it is half used. off: never.
animatetrueThe highlight along the gauge.

An idle session's prompt cache expires after its TTL: 1 hour on a subscription, 5 minutes in overage or on an API key. The next prompt then writes the whole context to the cache again, at 1.25x (5 min) or 2x (1 h) the input price. For a 400k-token Opus 5.5 session on the 1-hour cache:

OptionCost
Let it expire, re-cache on return$3.20
One keep-alive (re-read the cached prefix; a read resets the TTL)$0.09
Compact before it expires~$0.87, plus detail lost to the summary

A few minutes before the cache expires (2.5 min on the 1-hour TTL, 45 s on the 5-minute one), the smart strategy:

  1. Sends keep-alives while they cost less than compacting would, counting lost detail as compactPenaltyUsd. It stops after maxIdleHours.
  2. Then compacts while the cache is still warm.
  3. Leaves small sessions alone: below minSavingsUsd of re-cache cost, or below minCompactTokens of context, it never compacts.

It never compacts while the prompt holds a draft or agents are running; it sends a keep-alive to hold the cache instead. If the machine sleeps past the deadline, it marks the cache expired and does nothing. A failed action is retried twice, 20 s apart. The TTL is detected from the plan and corrected from the cache hits and misses it observes.

On statusline's line it says whether the session is ready to prompt or has gone cold: ● ready, · cold in m:ss (amber in the last five minutes), · compacts in … when an idle compaction is near, ○ cold, or what it is doing (compacting, keeping warm, paused). /cache shows the live figures and plan; /cache pause, /cache resume and /cache keepalive control it.

OptionDefault
enabledtruefalse turns the mod off; /mods does it for all of the pack.
strategysmartcompact: compact at the first expiry. keepalive: never compact. off: status line only.
cacheTtlauto1h or 5m to override detection.
minSavingsUsd0.25Below this re-cache cost, the cache is left to expire.
compactPenaltyUsd0.5Higher keeps the cache warm longer before compacting.
minCompactTokens100000Never compacts a smaller context.
maxIdleHours6Stops keep-alives after this long idle.
compactInstructionsemptyPassed to idle compactions, like /compact <text>.

Prices are list prices, so on a subscription they are API-equivalent estimates, not a bill.

Every request of the main model re-reads its whole context: at 600k tokens on Opus 5.5 that is about $0.12 a step before any work is done. A worker reads only its own small context. Claude calls hand_off with a task that stands alone, its kind (read: changes no file; mechanical: the task spells out every change; build: something is left to work out), the symbols or file:line places it touches (focus), and for edits a check command. The kind picks the worker, so Claude has no model to weigh: Haiku at low effort for read and mechanical work, Sonnet at medium for build work.

  1. Where it reaches. For each focus entry the mod asks the language server for references and callers (with lsp-plus, the docs that mention it come along) and tells the worker.
  2. The worker. A subagent, read-only for read, with Edit and Write for mechanical and build. The call waits for it inside the tool call (one request of the main model) and returns the report with the evidence; with background it returns at once and the report arrives as a message. A follow-up within 50 minutes goes to the same worker, its reading still in its context.
  3. The evidence. The diff against a git snapshot taken at the start, however the worker edited; the check command, run by the mod itself; references outside the changed files; what the work cost against the same steps on the main model.
  4. The review. The main model judges the diff and the check, not the report.
  5. The log. Each hand-off goes to ~/.claude/mods/delegate/log.jsonl; /delegate sums it up and opens a side pane. While a worker runs, its card is drawn like a cell (the task, a row per step, the state and cost), and with eta loaded it is a row above the prompt too.

Measured on 120 Opus runs, all correct: a feature or tests cost 23–35% less handed off; lookups, traces, explanations and small edits cost more. Opus does not hand off unless told to, so ask for it as an instruction: "hand the tests to a worker". An order the mod gives works as well as yours (8 of 8 runs, 22–26% less), so when you approve a plan of 20 lines or more, delegate gives that order: plan in plan mode, approve, and a Sonnet worker builds it. Corrected 16 Oct: worker costs were undercounted; these were 27–37% and 27–33%.

A verified hand-off (0.3.0). The worker ends its report with a VERIFY block: the command that runs only its new tests, and what it left open. d

Source 2 files
hooks/register.ts 273 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { loadedMods, MODS, modRows, modsPane, modsText, type Elements } from './mods'
4
5type Engine = EngineInterface
6
7// Hook guard: another plugin's PermissionRequest or Notification command hook that hangs must not freeze
8// Claude Code. Skipping only these two is safe (PermissionRequest skipped: Claude Code asks the person; Notification
9// is cosmetic). PreToolUse is never touched.
10
11const FIRST_LIMIT_MS = 10_000
12const AFTER_HANG_LIMIT_MS = 2_000
13
14/** Marks that the limit ran out before the chain beneath answered. */
15const TIMED_OUT = Symbol('timed out')
16
17type Race<R> = { timedOut: false; result: R } | { timedOut: true }
18
19/**
20 * Waits for `run()` (the `next(e)` call) at most `limitMs`, by racing it against a `$.clock.sleep`.
21 * The sleep counts against the hook's own budget (a $.clock wait is the one `$` call that does), so
22 * the limit is cut to stay under what `budget.remainingMs` leaves; the hook's `.catch` is the net.
23 */
24async function race<R>($: Engine, run: () => Promise<R>, limitMs: number, remainingMs: number): Promise<Race<R>> {
25  const stop = new AbortController()
26  const ms = Math.max(0, Math.min(limitMs, remainingMs - 500))
27  const timer = $.clock.sleep(ms, { signal: stop.signal }).then(
28    () => TIMED_OUT,
29    // Aborted because the chain answered first: this side never wins.
30    () => new Promise<never>(() => undefined),
31  )
32  const chain = run()
33  // If the timer wins, a later rejection of the abandoned chain must not surface.
34  chain.catch(() => undefined)
35  try {
36    const r = await Promise.race([chain, timer])
37    return r === TIMED_OUT ? { timedOut: true } : { timedOut: false, result: r as R }
38  } finally {
39    stop.abort()
40  }
41}
42
43type HookEntry = { hooks?: { type?: string; command?: string }[] }
44
45const hasCommand = (v: unknown, event: string): boolean => {
46  const list = (v as Record<string, unknown> | undefined)?.[event]
47  return (
48    Array.isArray(list) &&
49    (list as HookEntry[]).some(h => Array.isArray(h?.hooks) && h.hooks.some(c => (c?.type ?? 'command') === 'command'))
50  )
51}
52
53/** The enabled plugins and settings that define a command hook for `event`: best effort, may be empty. */
54async function hookSources($: Engine, event: string): Promise<string[]> {
55  const out: string[] = []
56  try {
57    const settings = (await $.settings.read()) as { enabledPlugins?: Record<string, unknown> }
58    const home = (await $.env.get('HOME')) ?? ''
59    // The merged settings carry the plugins' hooks too: the person's own are in their files.
60    const cwd = await $.session.cwd()
61    for (const f of [`${home}/.claude/settings.json`, `${home}/.claude/settings.local.json`, `${cwd}/.claude/settings.json`, `${cwd}/.claude/settings.local.json`]) {
62      const own = await $.fs.read(f).then(t => JSON.parse(t) as { hooks?: unknown }).catch(() => undefined)
63      if (own !== undefined && hasCommand(own.hooks, event)) {
64        out.push('your settings')
65        break
66      }
67    }
68    for (const [id, on] of Object.entries(settings.enabledPlugins ?? {})) {
69      if (on === false) continue
70      const [name, market] = id.split('@')
71      const dir = `${home}/.claude/plugins/cache/${market}/${name}`
72      const versions = await $.fs.list(dir).catch(() => [])
73      for (const v of versions) {
74        if (v.kind === 'file') continue
75        const path = `${dir}/${v.name}/hooks/hooks.json`
76        if (!(await $.fs.exists(path).catch(() => false))) continue
77        const json = JSON.parse(await $.fs.read(path)) as { hooks?: unknown }
78        if (hasCommand(json.hooks ?? json, event)) {
79          out.push(id)
80          break
81        }
82      }
83    }
84  } catch {
85    // unreadable settings: name no source
86  }
87  return out
88}
89
90function notice(event: string, sources: readonly string[], limitMs: number): string {
91  const who = sources.length ? ` (${sources.join(', ')})` : ''
92  return `a ${event} hook${who} ran over ${limitMs / 1000} s, so Claude Code went on without it. hookGuard: off in the modpack settings turns this off.`
93}
94
95async function loaded($: Engine) {
96  const commands = await $.command.list().catch(() => [])
97  const tools = await $.tool.list().catch(() => [])
98  return loadedMods(commands, tools)
99}
100
101const REPO = 'HarzerHeribert/claude-modpack'
102const MARKETPLACE = 'claude-modpack'
103const PLUGINS = ['modpack', 'statusline', 'smart-compact', 'delegate', 'lsp-plus', 'cells', 'eta', 'seek', 'route']
104const THROTTLE_MS = 20 * 3_600_000
105
106const MODS_PANE = 'mods'
107
108let mode = 'auto'
109let hung = false
110let told = false
111
112const parts = (v: string) => v.replace(/^v/, '').split('.').map(p => Number.parseInt(p, 10) || 0)
113
114/** True when semver `b` is newer than `a` (numeric per part; a leading "v" is allowed). */
115export function newer(a: string, b: string): boolean {
116  const x = parts(a)
117  const y = parts(b)
118  for (let i = 0; i < Math.max(x.length, y.length); i++) {
119    const d = (y[i] ?? 0) - (x[i] ?? 0)
120    if (d !== 0) return d > 0
121  }
122  return false
123}
124
125/** The release's version from the GitHub API's JSON: tag_name "v0.3.0" gives "0.3.0". */
126export function releaseVersion(json: unknown): string | undefined {
127  let o: unknown = json
128  if (typeof json === 'string') {
129    try {
130      o = JSON.parse(json)
131    } catch {
132      return undefined
133    }
134  }
135  const tag = (o as { tag_name?: unknown } | null | undefined)?.tag_name
136  return typeof tag === 'string' && tag.trim() !== '' ? tag.trim().replace(/^v/, '') : undefined
137}
138
139const url = (latest: string) => `https://github.com/${REPO}/releases/tag/v${latest}`
140
141async function claude($: Engine, args: string[]) {
142  return $.process.run(['claude', ...args], { timeoutMs: 120_000 }).catch(() => undefined)
143}
144
145const firstLine = (s: string | undefined) => (s ?? '').trim().split('\n')[0] || 'no error text'
146
147/** Runs the marketplace update and each plugin's; the outcome as the line the person reads. */
148async function install($: Engine, latest: string, current: string): Promise<string> {
149  const m = await claude($, ['plugin', 'marketplace', 'update', MARKETPLACE])
150  if (m === undefined || m.exitCode !== 0) return failed(latest, m?.stderr)
151  let own: Awaited<ReturnType<typeof claude>>
152  for (const name of PLUGINS) {
153    const r = await claude($, ['plugin', 'update', `${name}@${MARKETPLACE}`])
154    // A mod new in this release (route in 0.5.0, seek in 0.6.0) is not installed yet: update cannot reach it, install does.
155    if (name !== 'modpack' && (r === undefined || r.exitCode !== 0)) await claude($, ['plugin', 'install', `${name}@${MARKETPLACE}`])
156    if (name === 'modpack') own = r
157  }
158  if (own === undefined || own.exitCode !== 0) return failed(latest, own?.stderr)
159  return `claude-modpack ${latest} is installed (you were on ${current}): it loads the next time Claude Code starts; claude --continue keeps this conversation. What's new: ${url(latest)}`
160}
161
162const failed = (latest: string, stderr: string | undefined) =>
163  `claude-modpack ${latest} is out, but the update failed: ${firstLine(stderr)}. /modpack:update tries again.`
164
165type Outcome = { text?: string; current: string; latest?: string; folder?: boolean; root: string }
166
167/** The check, and the install when `install` is set. `force` ignores the throttle. Undefined text: nothing to say. */
168async function check($: Engine, force: boolean, doInstall: boolean): Promise<Outcome> {
169  // MODPACK_PLUGIN_ROOT stands in for $.plugin.root: a seam for tests, which cannot move the real one.
170  const root = (await $.env.get('MODPACK_PLUGIN_ROOT')) || $.plugin.root
171  const home = (await $.env.get('HOME')) ?? ''
172  const current = JSON.parse(await $.fs.read(`${root}/.claude-plugin/plugin.json`)).version as string
173  if (!root.startsWith(`${home}/.claude/plugins/`)) return { current, folder: true, root }
174  const statePath = `${home}/.claude/mods/modpack/update.json`
175  const now = await $.clock.now()
176  if (!force) {
177    const st = await $.fs.exists(statePath).catch(() => false)
178    const saved = st ? await $.fs.read(statePath).then(t => JSON.parse(t) as { checkedAt?: number }).catch(() => undefined) : undefined
179    if (typeof saved?.checkedAt === 'number' && now - saved.checkedAt < THROTTLE_MS) return { current, root }
180  }
181  const res = await $.http.fetch(`https://api.github.com/repos/${REPO}/releases/latest`, {
182    headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'claude-modpack' },
183  }).catch(() => undefined)
184  const latest = res?.ok ? releaseVersion(res.text) : undefined
185  if (latest === undefined) return { current, root }
186  let text: string | undefined
187  if (newer(current, latest)) {
188    if (doInstall) text = await install($, latest, current)
189    else text = `claude-modpack ${latest} is out (you have ${current}): /modpack:update installs it. What's new: ${url(latest)}`
190  }
191  await $.fs.write(statePath, JSON.stringify({ checkedAt: now, latest })).catch(() => undefined)
192  return { text, current, latest, root }
193}
194
195async function guarded(event: string, $: Engine, e: unknown, next: unknown, guard: boolean) {
196  const n = next as ((e: unknown) => Promise<unknown>) & { budget: { remainingMs: number } }
197  if (!guard) return n(e)
198  const limit = hung ? AFTER_HANG_LIMIT_MS : FIRST_LIMIT_MS
199  const r = await race($, () => n(e), limit, n.budget.remainingMs)
200  if (!r.timedOut) return r.result
201  hung = true
202  if (!told) {
203    told = true
204    const sources = await hookSources($, event)
205    await $.ui.log(notice(event, sources, limit))
206  }
207  return {} // no decision: Claude Code goes on to its own dialog
208}
209
210async function setMod($: Engine, mod: string, action: string | undefined): Promise<string> {
211  const names = MODS.map(m => m[0])
212  if (!names.includes(mod as never)) return `No mod called ${mod}. The pack's mods: ${names.join(', ')}.`
213  if (action !== undefined && action !== 'on' && action !== 'off') return `Say /mods ${mod} on or /mods ${mod} off.`
214  const config = await $.config.list()
215  const row = modRows(config, await loaded($)).find(r => r.name === mod)
216  if (row === undefined || row.state === 'not installed') return `${mod} is not installed: ${row?.install ?? ''}`
217  if (row.folder) return `${mod} is loaded from a folder, so it has no row in /config: its switch is \`enabled\` under pluginConfigs in your settings.`
218  const key = `${mod}.enabled`
219  const cfg = config.find(r => r.key === key)
220  if (cfg === undefined) return `${mod} has no on/off switch.`
221  if (cfg.isLocked) return `${mod}'s switch is locked by your settings policy; /mods cannot change it.`
222  const value = action === undefined ? cfg.value === false : action === 'on'
223  await $.config.set({ key, value })
224  await $.ui.invalidate('ui.render')
225  return `${mod} is now ${value ? 'on' : 'off'}. /mods ${mod} ${value ? 'off' : 'on'} turns it back ${value ? 'off' : 'on'}.`
226}
227
228export const register: Register = (on, options) => {
229  const u = options.updates
230  mode = u === 'notify' || u === 'off' ? u : 'auto'
231
232  on('session.start', async ($, e, next) => {
233    await $.command.register({ name: 'update', description: 'Update claude-modpack and its mods to the newest release' })
234    await $.command.register({ name: 'mods', description: "See the pack's mods, and turn each on or off", argumentHint: '[mod] [on|off]' })
235    if (mode !== 'off') {
236      $.clock.after(0, () => {
237        void check($, false, mode === 'auto').then(o => {
238          if (o.text) $.ui.log(o.text)
239        }).catch(() => undefined)
240      })
241    }
242    return next(e)
243  })
244
245  on('command.run', { command: 'update' }, async $ => {
246    const o = await check($, true, true)
247    if (o.folder) return { text: `claude-modpack is loaded from ${o.root}: update it there (git pull); a folder copy reloads by itself.` }
248    if (o.latest === undefined) return { text: 'Could not reach GitHub to look for a new release. Try again later.' }
249    return { text: o.text ?? `Already the newest: ${o.current}.` }
250  })
251  on('command.run', { command: 'mods' }, async ($, e) => {
252    const argv = (e.args ?? '').trim().split(/\s+/).filter(Boolean)
253    if (argv.length > 0) return { text: await setMod($, argv[0] as string, argv[1]) }
254    const text = modsText(modRows(await $.config.list(), await loaded($)))
255    await $.ui.open({ id: MODS_PANE, title: 'Mods' }).catch(() => undefined)
256    return { text }
257  })
258
259  // The hook guard: next(e) raced against a $.clock wait; see guard.ts.
260  hung = false
261  told = false
262  const guard = options.hookGuard !== false
263  // The hook's 10 s budget can end before a 10 s wait does: the handler then also answers with no decision.
264  on('classic.PermissionRequest', ($, e, next) => guarded('PermissionRequest', $, e, next, guard) as never).catch(() => ({}))
265  on('classic.Notification', ($, e, next) => guarded('Notification', $, e, next, guard) as never).catch(() => ({}))
266
267  on('ui.render', { component: 'Pane', requestId: MODS_PANE }, async ($, e) => {
268    const flip = (key: string, value: boolean) =>
269      void $.config.set({ key, value }).then(() => $.ui.invalidate('ui.render')).catch(() => undefined)
270    return modsPane($.ui.resolve(e) as unknown as Elements, modRows(await $.config.list(), await loaded($)), flip)
271  })
272}
273
hooks/mods.tsx 89 lines
1// /mods: which of the pack's mods are loaded, and a button on each to turn it off or on.
2
3import type { BoxProps, ButtonProps, ConfigRow, ElementConstructor, RenderElement, TextProps } from 'claude-code'
4
5export type Elements = {
6  Box: ElementConstructor<BoxProps>
7  Text: ElementConstructor<TextProps>
8  Button: ElementConstructor<ButtonProps>
9}
10
11const MARKETPLACE = 'HarzerHeribert/claude-modpack'
12
13const FOLDER = "loaded from a folder: its `enabled` setting is in your settings' pluginConfigs"
14
15export const MODS = [
16  ['statusline', 'one line under the prompt: model, context, plan use'],
17  ['smart-compact', 'keeps the prompt cache warm or compacts before it expires'],
18  ['delegate', 'hands a task to a cheaper worker model and checks its work'],
19  ['lsp-plus', 'the language server\'s answers for greps, docs in references'],
20  ['cells', 'several edits and their check in one step'],
21  ['eta', 'a percentage and finish time for long-running jobs'],
22  ['seek', 'has Claude look around your code in few, lean steps'],
23  ['route', 'sends feature and test work to delegate\'s worker (Jev or ScaleDown; off until you turn it on)'],
24] as const
25
26export type ModRow = {
27  name: string
28  does: string
29  state: 'on' | 'off' | 'not installed'
30  /** The key to flip and the value to set; absent when not installed or locked. */
31  flip?: { key: string; value: boolean }
32  install?: string
33  /** Loaded without /config rows (a folder): no button; its setting lives in pluginConfigs. */
34  folder?: boolean
35}
36
37/** The mods seen through commands, tool names (folder-loaded plugins have no /config rows). */
38export function loadedMods(commands: readonly { plugin?: string }[], tools: readonly { name: string }[]): Set<string> {
39  const out = new Set<string>()
40  for (const [name] of MODS) {
41    if (commands.some(c => c.plugin === name) || tools.some(t => t.name.startsWith(`mcp__${name}__`))) out.add(name)
42  }
43  return out
44}
45
46
47
48/** One row per mod of the pack, from the /config rows; `loaded` names mods seen another way (a folder has no rows). */
49export function modRows(config: readonly ConfigRow[], loaded: ReadonlySet<string> = new Set()): ModRow[] {
50  return MODS.map(([name, does]) => {
51    const key = `${name}.enabled`
52    const row = config.find(r => r.key === key)
53    if (row === undefined) {
54      if (config.some(r => r.key.startsWith(`${name}.`))) return { name, does, state: 'on' as const }
55      if (loaded.has(name)) return { name, does, state: 'on' as const, folder: true }
56      return { name, does, state: 'not installed' as const, install: `/plugin install ${name} --marketplace ${MARKETPLACE}` }
57    }
58    const on = row.value !== false
59    return { name, does, state: on ? ('on' as const) : ('off' as const), ...(row.isLocked ? {} : { flip: { key, value: !on } }) }
60  })
61}
62
63export function modsText(rows: readonly ModRow[]): string {
64  const lines = rows.map(r => `${r.name}: ${r.state} (${r.does})${r.install ? `\n  ${r.install}` : ''}${r.folder ? `\n  ${FOLDER}` : ''}`)
65  return `The pack's mods:\n${lines.join('\n')}\n/mods opens a pane where each installed one turns on or off.`
66}
67
68export function modsPane(el: Elements, rows: readonly ModRow[], onFlip: (key: string, value: boolean) => void): RenderElement {
69  const { Box, Text, Button } = el
70  return (
71    <Box flexDirection="column">
72      {rows.map(r => (
73        <Box key={r.name} flexDirection="column" borderStyle="round" borderColor={r.state === 'on' ? 'claude' : 'subtle'} paddingX={1}>
74          <Box>
75            <Text bold color={r.state === 'on' ? 'claude' : undefined} dimColor={r.state !== 'on'}>{r.name}</Text>
76            <Text dimColor>{`  ${r.state}`}</Text>
77          </Box>
78          <Text dimColor>{r.does}</Text>
79          {r.folder === true && <Text dimColor>{FOLDER}</Text>}
80          {r.install !== undefined && <Text dimColor>{r.install}</Text>}
81          {r.flip !== undefined && (
82            <Button key={`toggle-${r.name}`} plain label={r.state === 'on' ? 'turn off' : 'turn on'} onPress={() => onFlip((r.flip as { key: string }).key, (r.flip as { value: boolean }).value)} />
83          )}
84        </Box>
85      ))}
86    </Box>
87  )
88}
89