SLOPSHOPPER

mod-box

21 Claude Code mods in one plugin, each switchable on its own; /mod-box opens the window to switch, configure and import mods.

newpanebandrowsguardcommand
★ 1v0.6.4MIT AND Apache-2.0updated 2026-10-15LupusMalusDeviant/mod-box
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-box
│ ┃ Files: app ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ app󰁝 󰋜 󰑐 󰈈 󰋊 │ mod-box │ │ ┃  no git repo · git status starts after git ⏺ Read(src/auth.ts) │ Handoff written: .claude/handoff.local.md. │ │ ┃ init ⎿ Read 6 lines │ A new session in this folder offers │ │ ┃ / : search ⏎ jump ⏺ Update(src/auth.ts) │ "Continue". │ │ ┃ ▣ client module ./rows.tsx ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ ⎿ Denied by mod-box: Blast Radius held this command and did │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /mod-box │ ┃ ⎿ mod-box: Mod Box opened (Esc closes). │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ⟨Claude Code's own drawing⟩ 📋 handoff │ ⏱ 1h00 🔥 ⛅ 49% View × Compact Stop keep-warm ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ 📋 handoff │ ⏱ 1h00 🔥 ⛅ 49% View × Compact Stop keep-warm
Pane · Files: app
app󰁝 󰋜 󰑐 󰈈 󰋊  no git repo · git status starts after git init / : search ⏎ jump ▣ client module ./rows.tsx
Pane · Mod Box
🧰 Mod Box ◎ Default 12 / 21 on [ 🧩 Mods ] ◐ Profiles ⇩ Sources 🩺 Health ╭────────────────────────────────────────────────────────╮ │ ✘ Reading installed plugins × │ │ dev │ ╰────────────────────────────────────────────────────────╯ note-usage ╭────────────────────────────────────────────────────────╮ │ 🛡 Guards 4/6 │ │ t-blastRadius 👁 ▶ 🌐 ⚙ │ │ Holds risky shell commands (rm -rf, git reset --har… │ │ t-secretGuard 👁 ✎ ▶ /secret-allow ⚙ │ │ Stops before Claude writes API keys, tokens, privat… │ │ t-screenMask /mask ⚙ │ │ For screen sharing and recordings: hides API keys, … │ │ t-packageGuard ▶ 🌐 /pkg-allow ⚙ │ │ Before Claude installs or runs a package (npm, pnpm… │ │ t-ciWatch ✎ ▶ 🌐 /ci ⚙ │ │ After every git push, follows the GitHub Actions ru… │ │ t-signGuard 👁 ▶ ⚙ │ │ Spots Windows builds (cargo --release, dotnet publi… │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────╮ │ 👁 Display 3/9 │ │ t-savvyProgress /agents-info ⚙ │ │ Progress bar above the prompt and an agents panel │ │ t-filetree 👁 ✎ ▶ /filetree ⚙ │ │ File tree side pane (/filetree) │ │ t-replayTheater 👁 /replay ⚙ │ │ Step through the last turn's file edits one diff at… │ │ t-nextSteps 💰 👁 ⚙ │ │ After each answer, 2-3 likely next prompts │ │ t-codebaseMap 💰 🔒 👁 ✎ ▶ /codebase-map ⚙ │ │ Feature map of the codebase from docs/blueprints: d… │ │ t-pet 👁 ✎ ▶ 🔧 /pet ⚙ │ │ A little companion on the Windows desktop, always o… │ │ t-ccSplitDiffView 🔒 ⚙ │ │ Draws the result of Edit and Write as a split diff:… │ │ t-humanInTheLoop 🔒 👁 ✎ /my-tasks ⚙ │ │ Claude can hand you tasks only a person can do │ │ t-promptRail 🔒 👁 ▶ 🔧 /prompt-rail ⚙ │ │ A rail of the prompts of this session: bars above t… │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────── │ 🧠 Context & cache 5/5 │ t-cacheMeter 💰 👁 ✎ /keepwarm ⚙ │ Band: time left on the warm cache, hit rate, contex… │ t-subagentSaver ⚙ │ Search subagents (Explore) that were given no model… │ t-handoff 💰 👁 ✎ /handoff 1 call · 13 Token · ≈ < $0. │ /handoff writes a handoff note to .claude/handoff.l… │ t-visibleCompact 👁 ✎ /show-last-compact ⚙ │ Shows before and after of /compact side by side │ t-readGuard 👁 ⚙ │ Holds a whole-file Read (no offset/limit) of a file… ╰────────────────────────────────────────────────────────── ╭────────────────────────────────────────────────────────╮ │ 🔔 Watchers 0/1 │ │ t-catchMeUp 💰 👁 ▶ /catch-me-up ⚙ │ │ Side pane with a live summary of the session │ ╰────────────────────────────────────────────────────────╯ ╭────────────────────────────────────────────────────────── │ ⚙ General │ Language English ▾ │ Band layout compact ▾ │ watch hooks on ▾ │ Import strategy automatic ▾ │ Band order CI Watch › Sign Guard › Handoff Not ╰────────────────────────────────────────────────────────── ╭────────────────────────────────────────────────────────╮ │ 🔌 Managed natively 0/0 │ │ Plugins installed as themselves (skills, agents, │ │ MCP/LSP, settings hooks …). The box switches, │ │ configures, updates and removes them through Claude │ │ Code. │ │ None yet. Sources → "Install natively", or take over │ │ an installed plugin below. │ ╰────────────────────────────────────────────────────────╯ Click or Enter switches · ⚙ shows settings · Esc closes
Pane · Replay Theater
╭──────────────────────────────────────────────────────────╮ │ ▶ Replay Theater step 1 of 3 │ │ 1 2 3 │ │ src/auth.ts │ │ Edit +2 -1 │ │ │ │ - if (!claims) throw new Error('invalid token') │ │ + if (!claims || claims.exp < Date.now() / 1000) throw … │ │ + await audit('refresh', claims.sub) │ │ │ │ [ ◀ Prev ] [ Next ▶ ] [ Close ] │ ╰──────────────────────────────────────────────────────────╯
Pane · Agents
Collapse ≈$0.00 · 0 tokens · 0:00 No subagents yet.
Pane · Secret Guard · exceptions
Exceptions let known test keys and example files pass. Entries: a path in gitignore style (tests/fixtures/**, *.example.ts) or sha256:<hash> of a value – keys never stand in clear text in the list. Project exceptions live in .secretguard-allow (can be committed), global ones apply everywhere. Claude cannot change the list. Last finds Nothing yet. Project (.secretguard-allow) none New: : tests/fixtures/** ⏎ Add Global (all projects) none New: : *.example.env ⏎ Add Placeholders (EXAMPLE, DUMMY, FAKE, xxxx …) pass automatically.
Pane · mod-box-diff
No diff.
Pane · ci-watch
No red run.
Pane · handoff
/work/app/.claude/handoff.local.md · 09.10. 09:03 <!-- Mod Box handoff note, 2025-10-09T09:03:20.670Z --> OK
Pane · visible-compact
No compaction recorded for this session yet.
Pane · Blast Radius · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ⚠ Blast Radius · rm -rf │ │ Command rm -rf build && git push --force origin main │ │ Would delete nothing: no file matches build │ │ │ │ The paths don't exist, so rm has nothing to remove. │ │ │ │ 1: Proceed 2: Cancel Claude is waiting on your answer │ ╰──────────────────────────────────────────────────────────╯
README

Mod Box

CI License: MIT Claude Code 2.1.287+

One mod to manage your Claude Code mods: switch, configure and profile them in one window. It ships with my best-of selection of fifteen mods, and takes any others you add.

Claude Code mods are small hook modules that change how Claude Code behaves and looks: a guard that stops rm -rf before it runs, a meter that shows when the prompt cache goes cold, a watcher that follows your CI after every push. There are a lot of good ones scattered over GitHub. The Mod Box collects them, makes them work together, and gives you one place to switch them, configure them and add more.

<!-- screenshot: the Mods tab -->

Highlights

  • One window (/mod-box): every mod as a card with a switch, its options, commands and token cost; profiles; sources; health.
  • Guards: Blast Radius holds destructive shell commands, Secret Guard stops keys and .env files before they reach a file or commit, Screen Mask hides them (and e-mails, IPs, names) on screen while you share it, Package Guard holds installs of made-up, brand-new or typosquatted packages, CI Watch follows every push and pull request to the end.
  • Saves money and limits: Cache Meter shows the warm cache and plan limits and stops a send to a cold cache with its price; Subagent Saver runs search subagents on Sonnet instead of your main model; auto-compact can kick in earlier.
  • Grows with you: import mods from any git repo or marketplace, or ask Claude to add one with the bundled mod-box-add skill. Profiles are rules, so new mods land in the right ones; foreign code stays off until you trust it.
  • Knows where it runs: each mod declares surfaces, systems and tools it needs; the window warns when one would not work here.
  • English and German UI.

Most of the mods are adapted copies of other people's mods, credited below with what was changed. The rest were written for this box.

Install

Needs Claude Code 2.1.287 or newer. In a terminal Claude Code session:

/plugin install mod-box --marketplace LupusMalusDeviant/mod-box

Answer y to add the marketplace, then pick a scope. Or from a shell:

claude plugin marketplace add LupusMalusDeviant/mod-box
claude plugin install mod-box@mod-box

Then open the window with /mod-box. A mod installed at user scope also runs in the desktop app's Code tab.

Update with claude plugin update mod-box@mod-box and a restart of Claude. Remove with claude plugin uninstall mod-box@mod-box.

Working on the box itself: have Claude Code load it straight from your clone. In ~/.claude/settings.json, under env, set CLAUDE_CODE_PLUGIN_DIRS to the folder and CLAUDE_CODE_PLUGIN_DIR_WATCH to 1, then restart Claude once. After that, every saved change reloads the box by itself. The tools in tools/ need Node.js 26. See CONTRIBUTING.md.

Just one mod?

The own mods and the forks with real changes are also available one by one, without the box: claude-mods (Cache Meter, Subagent Saver, Read Guard, Secret Guard, Screen Mask, Package Guard, CI Watch, Sign Guard, Handoff Note, Desktop Pet, and Blast Radius as a marked fork). For the other mods, use their originals; they are linked under Credits.

Safety

  • Mods run inside your Claude Code session with the rights of a plugin: they can read and write files and run commands. Only add mods from sources you trust. Foreign mods, integrated or natively managed, start switched off and stay off in every profile until you trust them. Trust is your decision, not a sandbox.
  • Looking is safe: inspecting a plugin, the Sources tab and its list of parts read files only and run nothing.
  • Secrets stay in Claude Code's credential store. The box never shows, logs or reads back a sensitive option; the window only says whether it is set.
  • Validation is not an audit: claude plugin validate proves that a plugin loads, not that it is harmless.
  • Secret Guard, Package Guard and Blast Radius are guards, not guarantees.
  • The box writes to ~/.claude/settings.json only where you ask it to: switches and options (in pluginConfigs, where the desktop app does not list plugin rows in /config) and the auto-compact window when you set it.
  • The box sends none of your data anywhere. It only fetches from git sources you add, and CI Watch talks to GitHub through your own gh. Mods that call a model (Next Steps, Catch Me Up, the handoff note, keep-warm) say so and show their token use.

The window

/mod-box opens the window. /mod-box profiles, sources and diag open it on a given tab. The window speaks English by default, or German with the language setting.

  • Mods: the mods as cards, one per group. ● means a mod is on, ○ means off; click a mod to switch it. ⚙ opens its description, its token cost and its options. The window has two columns when it is wide enough.
  • Profiles: set all switches at once: Default, Lean (nothing that always costs tokens), Guards only, Everything on. Profiles are rules, not lists (see Profiles and new mods).
  • Sources: connected Claude marketplaces show up here by themselves, and you can add your own git links (a marketplace, a plugin repo or a collection of mods, as owner/repo or a URL). The Import button fetches a mod with function hooks, rewrites it for the box and validates it. The imported mod starts switched off, and if validation fails, the box stays as it was. Updates of the originals checks the copied mods against their upstream repos (see below).
  • Health: shows four things:
  • token use of the mods that call a model;
  • hook failures in this session;
  • an environment check (node, git, gh signed in, Git Bash, the signing script);
  • a Validate button.

If Claude Code runs with --debug-file ~/.claude/mod-box/debug.log, this tab also shows the box's last error lines from that log.

Some hosts, the desktop app among them, don't list a plugin's options in /config. There the window builds the rows itself and writes changes to pluginConfigs in ~/.claude/settings.json, which is where Claude Code keeps them.

In the terminal

The same window runs in terminal Claude Code, as a side pane next to the conversation.

<table> <tr><td><img src="docs/images/cli-mods.png" alt="Mods tab in the terminal" width="400"></td><td><img src="docs/images/cli-profiles.png" alt="Profiles tab in the terminal" width="400"></td></tr> <tr><td><img src="docs/images/cli-sources.png" alt="Sources tab in the terminal" width="400"></td><td><img src="docs/images/cli-health.png" alt="Health tab in the terminal" width="400"></td></tr> <tr><td colspan="2" align="center"><img src="docs/images/cli-filetree.png" alt="Filetree side pane in the terminal" width="400"></td></tr> </table>

The mods

SwitchModDefaultWhat it doesCosts tokens?
blastRadiusBlast RadiusonHolds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would hit.no
packageGuardPackage GuardonBefore an install or npx/uvx run (npm, pnpm, yarn, bun, pip, uv, poetry, pipx, cargo) asks the public registry; holds the command when the package does not exist, is under 14 days old, was removed as malware, or looks like a well-known package and is young or thin. Mode block or warn; allow in the band or with /pkg-allow. Sends only the package name to the registry.no
secretGuardSecret GuardonStops before an API key, token, private key or non-ignored .env file gets into a file, git add or a commit. Mode block or warn; exceptions via /secret-allow.no
screenMaskScreen MaskoffFor screen sharing and recordings: hides API keys, tokens, e-mail and IP addresses, your login name in paths and names you list, in the display only. Claude still reads the original; nothing leaves the machine. /mask switches it; the band shows when it is on.no
ciWatchCI WatchonFollows the GitHub Actions runs after each git push (via gh) to the end. For an open pull request it watches every check and says whether the PR is ready to merge. When a run turns red, it shows the failed step's log and/or puts an analysis request into the prompt; it can also play a sound. /ci shows the state. A system notification when a run ends (default: red only), since Claude Code notifies only at the end of a turn. In the desktop app, a new PR's checks are left to the app's own CI bar.no
signGuardSign GuardoffSpots Windows builds and signs the new .exe, .msi and .msix files with your own script (<script> -Exe <file>). Mode ask or auto.no
savvyProgressSavvy ProgressonProgress bar and agents panel (/agents-info).no
filetreeFiletreeonFile tree side pane (/filetree) that lights up as Claude reads and writes. Right-click a file or folder (or press o) for Open with: the default app, the editors, IDEs and browsers found on this machine (by file type), preview, show in folder, copy path; Shift+right-click copies the path. With the Codebase Map on, a Features tab lists its features (live marks, uses / used by, files that jump to the tree) and the blueprint actions.no
replayTheaterReplay TheateronSteps through the last turn's edits (/replay).no
nextStepsNext StepsoffSuggests 2–3 next prompts. Claude Code has one built-in suggestion (Tab); this offers two or three.a Haiku call per answer
cacheMeterCache Meter (with Cache Tax)onShows the time left on the warm cache, the hit rate, the context as weather with its trend over the last 12 turns (growth per turn, turns until full) and the plan limits, with compact and keep-warm buttons. It also stops a send to a cold cache once and names its cost, and the cost of compacting first instead. Auto-compact at sets Claude Code's autoCompactWindow: on 1M-context models Claude otherwise compacts only at about 967k tokens.only keep-warm requests, when on
readGuardRead GuardonHolds a whole-file Read (no offset/limit) above the limit (default 50 KB), of a lockfile, minified code or a source map, and the same via cat, type or Get-Content; tells Claude the size, a token estimate and a better way (Grep, offset/limit). A second try at the same file goes through, and so does a Read with allow_large (Claude Code 2.1.296+). Claude Code itself stops a Read only at about 25,000 tokens.no
subagentSaverSubagent SaveronSearch subagents (Explore) that got no model of their own run on Sonnet (or Haiku) instead of inheriting the main model. An explicit model, forks, teammates and plugin agents stay untouched; the band shows how many were routed.no
handoffHandoff Noteon/handoff writes .claude/handoff.local.md, and a new session offers to continue from it.one request over the warm cache
petDesktop PetoffWindows only: a little companion on the desktop, always on top, that says in a speech bubble what Claude is doing (file read or changed, command running, what a guard held) and sums up each turn. A cardboard box or one of twelve figures (cat, dog, fox, bunny, hamster, owl, penguin, frog, axolotl, dragon, robot, ghost), cat, dog and bunny in nine coats, or a random one per session. Claude's questions and Next Steps' suggestions show as buttons in the bubble: a click answers or sends them, and whichever answers first, the pet or Claude Code's dialog, counts. An answer that ends with a question shows that question, with two or three short replies from Haiku as buttons. A click on the pet brings back the last answer in short, with its buttons while they still apply, and a name tag under it says which project it stands for. A bubble with a question or buttons that nobody answers shrinks to one line after the bubble time (or at once with its –); a click on that line or the pet opens it again. Permission requests are shown; with permission requests: answer the bubble also offers Allow once and Deny for 90 seconds (never "always", no rule is added), otherwise approving stays in Claude Code's dialog. It watches the prompt cache: a few minutes before it goes cold it says so, with a button that keeps it warm for an hour (Cache Meter on). Optionally one pet per active session, side by side, each with its own figure: when a session's cache goes cold its pet goes to sleep, and the next message brings it back. With type answers in the pet on (off by default), a hotkey (Ctrl+Alt+Space), the pet's menu or Write your own answer in the bubble opens a small input box: the text goes to Claude as the next prompt, as the answer to its open question, or in place of a suggestion, and the focus goes back to the game. Drag it anywhere; right-click for quiet time or Figur to pick another figure and coat. Built once with the C# compiler that ships with Windows into %LOCALAPPDATA%\mod-box\pet, signed with the Sign Guard script when one is set.only with summary haiku
ccSplitDiffViewSplit DiffoffDraws the result of Edit and Write as a side-by-side diff with line numbers; only the drawing changes.no
humanInTheLoopHuman in the LoopoffClaude hands you tasks only a person can do (a secret to put in place, an account it cannot reach, a decision); they stay in the band and in /my-tasks until you answer or mark them done.no
promptRailPrompt RailoffA rail of this session's prompts above the input (terminal) or beside the transcript; hover to read one, click to jump to it (/prompt-rail).no
visibleCompactVisible CompactonBefore/after view of /compact (/show-last-compact).no
catchMeUpCatch Me UpoffLive summary of the session (/catch-me-up), kept up after turns with tools. Claude Code's built-in recap (/recap) is one short note when you come back; this pane stays open.Haiku calls after turns
codebaseMapCodebase MapoffFeature map from docs/blueprints with live highlighting of the features Claude edits, a freshness band and a CLAUDE.md pointer; mapper instructions with machine-checked edges load only when you ask for blueprint work (/codebase-map). In the prompt, @ and part of a feature name offers its blueprint to attach, and an @file gets one line on the feature it belongs to.only blueprint runs you ask for; about 500 tokens per session for the CLAUDE.md pointer

Commands

CommandModWhat it does
`/mod-box [profilessourcesdiag]`Mod Boxopens the window, optionally on a tab
/pkg-allow [name]Package Guardallows a held package for this session
/secret-allowSecret Guardthe exception list: last finds, project and global entries
`/mask [on\off]`Screen Maskhide secrets and names on screen, or show them again
/ci · `/ci pr <nrurl> · /ci stop`CI Watchstate of the watched runs · watch a pull request · stop watching
/agents-infoSavvy Progressagents panel
/filetreeFiletreeopens the file tree
/my-tasksHuman in the Loopyour open tasks from Claude
`/prompt-rail [vertical\horizontal\off\next\prev\<nr>\find <words>]`Prompt Railswitch the rail, jump to a prompt
/replayReplay Theatersteps through the last turn's edits
`/keepwarm <6h90malwaysoffstatus> · /cache-tax`Cache Meterkeeps the cache warm · cache costs of this session
/pet [stop]Desktop Petshows the pet, or sends it off
/pet <figure> [coat]Desktop Petpicks a figure, e.g. /pet cat calico, /pet random; with one pet per session for this session only
/handoffHandoff Notewrites the handoff note
/show-last-compactVisible Compactbefore/after of the last /compact
/catch-me-upCatch Me Uplive summary pane
`/codebase-map [create\update [feature]\check\publish [feature]]`Codebase Mapopens the feature map; with an argument, prepares the blueprint prompt (for one feature, if named)

The window lists them too: next to each mod's name, and in full under ⚙.

The band above the prompt

CI Watch, Sign Guard, Cache Meter and Handoff Note hand their displays to the box, which draws them all in one band above the prompt:

  • Band layout compact puts calm displays on one shared line, and urgent ones (red CI, a sign offer) on lines of their own. lines gives each display its own line.
  • Display, per mod, chooses band, status line or off.
  • Order is set with arrows in the Mods tab.

Profiles and new mods

Each mod in box.json carries attributes: group (guards, display, context, watchers), tokens (none, optional, always), default, and compat (see below). A profile is a rule over them: Default takes each mod's default, Lean takes tokens: none | optional, Guards only takes group: guards, Everything on takes all. So a new mod lands in the right profiles as soon as it is classified.

The window remembers the profile you applied last (Default until you apply one). At the next session start it gives every mod that is new since then the switch that profile wants, and marks it new. If you have changed switches by hand since, the header says (changed); new mods still follow the profile.

Foreign code waits for trust. A mod imported from another repo starts switched off, and profiles leave it off until you trust it: Trust under its ⚙, or switch it on by hand once. Imported mods show 🔒 until then, 📦 after.

What a mod may do. The build reads each mod's source for the calls it makes, and the window shows the result next to its name: 👁 reads files or the transcript, ✎ writes files, the transcript or the prompt, ▶ runs programs, 🌐 goes online, 🔧 changes settings. Under ⚙ the same in words, with the programs by name. It is a reading of the code, not a sandbox, but it means a new mod cannot surprise you with what it touches.

Where a mod runs

Each mod declares where it works: the surfaces (terminal, desktop app, VS Code, mobile), the operating systems, and the programs it needs (git, gh signed in, Node.js, Git Bash, PowerShell, a signing script). The window compares that with the session you are in (surface, OS, and the environment check under Health) and puts a ⚠ with the reason next to a mod that would not, or not fully, run here. ⚙ shows the full list.

The Sources tab also searches the community mod catalogue of awesome-claude-code-mods (mods.aidojo.si, CC0): several thousand public mods, each with what Claude's validator says it may read, write, run or send. The box fetches the list once a day (node tools/box.mjs modcat --query <words>); Import takes a hit through the same checks as a git link.

Adding mods with Claude

The box ships the skill mod-box-add. Ask Claude something like "add the mod from owner/repo to the Mod Box", and it:

  • inspects the plugin and tells you its verdict (integrated, managed natively, blocked);
  • checks the license;
  • imports the mod with tools/box.mjs import, or installs it natively with tools/box.mjs native install;
  • reads the code and classifies it: group, tokens, where it runs, what it needs, its commands (tools/box.mjs analyze <id> gives a first guess to start from);
  • writes the texts in English and German;
  • marks the changed files and adds the credits;
  • runs build, validation and tests;
  • reports back in the language set in the box.

All instructions the box gives Claude (the skill, the prompts of the mods) are English. Only the language Claude answers in follows the setting.

Any plugin: integrated, managed natively, or not supported

Not every plugin can live inside the box. Before anything changes, the box reads the plugin (nothing of it runs) and gives one of these verdicts, shown in the Sources tab:

  • ◆ integrated: plain function-hook mods are copied into the box. They get a box switch, the band, profiles and box options. All hook modules, media and data files come along; option types are kept; $.store keys get the mod's prefix.
  • 🔌 managed natively: plugins with skills, slash-command files, agents, MCP or LSP servers, classic hooks, dependencies, secrets or ${CLAUDE_PLUGIN_ROOT} stay plugins of their own. The box installs them through Claude Code (claude plugin install), shows their parts and token cost, switches, configures, updates (with a diff) and removes them, all in the same window. Plugins without a marketplace go through the box's local marketplace. A plugin you installed yourself comes under the box with Adopt.
  • ⛔ blocked: a command or tool name is already taken, or there is no license. Integration waits until that is settled; the native way often still works.
  • ✗ not supported: for example a plugin whose hook module is missing.

Mods that ship with Claude Code itself (@builtin, such as You should know, a side agent that points out what you might miss) show up in the same card under Built into Claude Code, with a switch where Claude Code allows one. These switches apply to all projects.

The setting Import strategy chooses between automatic (integrate where nothing is lost, otherwise native) and always native. Trust and profiles work the same way for both kinds: a natively managed plugin is installed switched off, profiles skip it until you trust it, and once trusted it follows the profile rules by its group and token use.

The full matrix of what is handled how, before and after, the platform limits and the real plugins it was tested with are in docs/capabilities.md.

How the box is built

  • box.json is the single source: mods, switches, options (English, with _de translations), groups, state prefixes and profiles.
  • node tools/box.mjs build generates hooks/register.ts, the type contracts, the userConfig in .claude-plugin/plugin.json and the window's catalog. Don't edit those by hand.
  • tools/box.mjs also has export <id>... --out <folder> (the standalone plugins of claude-mods), catalog, inspect, import, reimport, analyze <id|dir>, remove, validate, updates [<id>], diff <id>, update <id>, native <action> (list, details, options, set, install, adopt, trust, untrust, enable, disable, update, uninstall, profile) and release (b
Source 51 files
hooks/register.ts 78 lines
1// GENERATED by tools/box.mjs build. Do not edit by hand: edit box.json and rebuild.
2// Dispatcher: registers each sub-mod only if its switch is on. Toggling one reloads.
3import type { Register } from 'claude-code'
4
5import { register as m0 } from '../mods/settings/hooks/register' // settings
6import { register as m1 } from '../mods/blast-radius/hooks/blast-radius.mjs' // blast-radius
7import { register as m2 } from '../mods/secret-guard/hooks/register' // secret-guard
8import { register as m3 } from '../mods/screen-mask/hooks/register' // screen-mask
9import { register as m4 } from '../mods/package-guard/hooks/register' // package-guard
10import { register as m5 } from '../mods/ci-watch/hooks/register' // ci-watch
11import { register as m6 } from '../mods/sign-guard/hooks/register' // sign-guard
12import { register as m7 } from '../mods/savvy-progress/hooks/register' // savvy-progress
13import { register as m8 } from '../mods/filetree/hooks/register' // filetree
14import { register as m9 } from '../mods/replay-theater/hooks/replay-theater.mjs' // replay-theater
15import { register as m10 } from '../mods/next-steps/hooks/register' // next-steps
16import { register as m11 } from '../mods/cache-meter/hooks/register' // cache-meter
17import { register as m12 } from '../mods/subagent-saver/hooks/register' // subagent-saver
18import { register as m13 } from '../mods/handoff/hooks/register' // handoff
19import { register as m14 } from '../mods/visible-compact/hooks/register' // visible-compact
20import { register as m15 } from '../mods/read-guard/hooks/register' // read-guard
21import { register as m16 } from '../mods/catch-me-up/hooks/register.js' // catch-me-up
22import { register as m17 } from '../mods/codebase-map/hooks/register' // codebase-map
23import { register as m18 } from '../mods/pet/hooks/register' // pet
24import { register as m19 } from '../mods/cc-split-diff-view/hooks/register' // cc-split-diff-view
25import { register as m20 } from '../mods/human-in-the-loop/hooks/register' // human-in-the-loop
26import { register as m21 } from '../mods/prompt-rail/hooks/index' // prompt-rail
27
28export function isOn(options: Record<string, unknown>, key: string, fallback: boolean): boolean {
29  const v = options[key]
30  return typeof v === 'boolean' ? v : v === 'true' ? true : v === 'false' ? false : fallback
31}
32
33export function opt(options: Record<string, unknown>, key: string, fallback: string): string {
34  const v = options[key]
35  return typeof v === 'string' && v !== '' ? v : typeof v === 'number' || typeof v === 'boolean' ? String(v) : fallback
36}
37
38export function optNumber(options: Record<string, unknown>, key: string, fallback: number | null): number | null {
39  const v = options[key]
40  const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
41  return Number.isFinite(n) ? n : fallback
42}
43
44export function optBool(options: Record<string, unknown>, key: string, fallback: boolean): boolean {
45  return isOn(options, key, fallback)
46}
47
48export function optList(options: Record<string, unknown>, key: string, fallback: string[]): string[] {
49  const v = options[key]
50  return Array.isArray(v) ? v.map(String) : typeof v === 'string' && v !== '' ? [v] : fallback
51}
52
53export const register: Register = (on, options) => {
54  const o = (options ?? {}) as Record<string, unknown>
55  ;(m0 as any)(on, o) // settings
56  if (isOn(o, "blastRadius", true)) (m1 as any)(on, {  })
57  if (isOn(o, "secretGuard", true)) (m2 as any)(on, { "mode": opt(o, "secretMode", "block"), "placeholders": opt(o, "secretPlaceholders", "on"), "language": opt(o, "language", "en") })
58  if (isOn(o, "screenMask", false)) (m3 as any)(on, { "terms": opt(o, "screenMaskTerms", ""), "emails": opt(o, "screenMaskEmails", "on"), "ips": opt(o, "screenMaskIps", "on"), "language": opt(o, "language", "en") })
59  if (isOn(o, "packageGuard", true)) (m4 as any)(on, { "mode": opt(o, "packageMode", "block"), "language": opt(o, "language", "en") })
60  if (isOn(o, "ciWatch", true)) (m5 as any)(on, { "intervalSec": opt(o, "ciInterval", "20"), "onRed": opt(o, "ciOnRed", "both"), "sound": opt(o, "ciSound", "off"), "notify": opt(o, "ciNotify", "red"), "prOnDesktop": opt(o, "ciPrOnDesktop", "app"), "language": opt(o, "language", "en") })
61  if (isOn(o, "signGuard", false)) (m6 as any)(on, { "mode": opt(o, "signMode", "ask"), "script": opt(o, "signScript", ""), "subject": opt(o, "signSubject", ""), "language": opt(o, "language", "en") })
62  if (isOn(o, "savvyProgress", true)) (m7 as any)(on, { "language": opt(o, "savvyLanguage", "auto") })
63  if (isOn(o, "filetree", true)) (m8 as any)(on, { "activity": opt(o, "filetreeActivity", "reads and writes"), "glyphs": opt(o, "filetreeGlyphs", "auto"), "follow": opt(o, "filetreeFollow", "on"), "column": opt(o, "filetreeColumn", "date"), "open": opt(o, "filetreeOpen", ""), "openWith": opt(o, "filetreeOpenWith", ""), "language": opt(o, "language", "en"), "codebaseMap": opt(o, "codebaseMap", "") })
64  if (isOn(o, "replayTheater", true)) (m9 as any)(on, {  })
65  if (isOn(o, "nextSteps", false)) (m10 as any)(on, { "language": opt(o, "language", "en") })
66  if (isOn(o, "cacheMeter", true)) (m11 as any)(on, { "ttlMinutes": opt(o, "cacheMeterTtl", "60"), "limits": opt(o, "cacheMeterLimits", "on"), "guard": opt(o, "cacheMeterGuard", "refuse"), "keepWarmHours": opt(o, "cacheMeterKeepWarm", "3"), "autoWarm": opt(o, "cacheMeterAutoWarm", "off"), "autoCompact": opt(o, "cacheMeterAutoCompact", "auto"), "language": opt(o, "language", "en") })
67  if (isOn(o, "subagentSaver", true)) (m12 as any)(on, { "model": opt(o, "subagentSaverModel", "sonnet"), "types": opt(o, "subagentSaverTypes", "Explore"), "language": opt(o, "language", "en") })
68  if (isOn(o, "handoff", true)) (m13 as any)(on, { "auto": opt(o, "handoffAuto", "off"), "language": opt(o, "language", "en"), "ttlMinutes": opt(o, "cacheMeterTtl", "60") })
69  if (isOn(o, "visibleCompact", true)) (m14 as any)(on, { "storage": opt(o, "compactStorage", "temp") })
70  if (isOn(o, "readGuard", true)) (m15 as any)(on, { "limitKb": opt(o, "readLimit", "50"), "mode": opt(o, "readMode", "block"), "shell": opt(o, "readShell", "on"), "language": opt(o, "language", "en") })
71  if (isOn(o, "catchMeUp", false)) (m16 as any)(on, { "language": opt(o, "language", "en") })
72  if (isOn(o, "codebaseMap", false)) (m17 as any)(on, { "claudeMd": opt(o, "codebaseMapClaudeMd", "auto"), "language": opt(o, "language", "en") })
73  if (isOn(o, "pet", false)) (m18 as any)(on, { "bubble": opt(o, "petBubble", "9"), "summary": opt(o, "petSummary", "off"), "kind": opt(o, "petKind", "box"), "fur": opt(o, "petFur", "auto"), "perSession": opt(o, "petPerSession", "off"), "cacheWarn": opt(o, "petCacheWarn", "5"), "ask": opt(o, "petAsk", "on"), "remind": opt(o, "petRemind", "60"), "suggest": opt(o, "petSuggest", "on"), "permissions": opt(o, "petPermissions", "show"), "typing": opt(o, "petTyping", "off"), "hotkey": opt(o, "petHotkey", "ctrl+alt+space"), "language": opt(o, "language", "en"), "signScript": opt(o, "signScript", ""), "cacheTtl": opt(o, "cacheMeterTtl", "60") })
74  if (isOn(o, "ccSplitDiffView", false)) (m19 as any)(on, {  })
75  if (isOn(o, "humanInTheLoop", false)) (m20 as any)(on, {  })
76  if (isOn(o, "promptRail", false)) (m21 as any)(on, { "mode": opt(o, "promptRailMode", "horizontal") })
77}
78
mods/settings/hooks/register.tsx 2114 lines
1// Mod Box window (/mod-box), four tabs:
2//  Mods      – every mod with its switch and its parameters; imported mods can be removed.
3//  Profiles  – set many switches at once (default, lean, guards only, everything on).
4//  Sources   – connected Claude marketplaces and own git links; import mods from them into the box.
5// Texts follow the "language" setting (de/en). Switches and parameters go through $.config.set
6// (like /config), which reloads the box. Importing, removing and scanning sources is done by
7// tools/box.mjs (Node).
8import { atom, read, update } from 'claude-code'
9import type { ConfigRow, EngineInterface, Register } from 'claude-code'
10
11import { openBand, takeBand } from './band'
12import { sumBook, tokensOf, USAGE_STORE } from './usage'
13import type { ModUsage, UsageBook } from './usage'
14import { BAND_IDS } from './bands'
15import { HOOKS } from './hooks-table'
16import { CATALOG, GROUPS, PLUGIN, PROFILES, SETTINGS } from './catalog'
17import type { CatalogField, CatalogMod, Text as I18n } from './catalog'
18import type { NativeField, NativeInfo, NativePlugin, SourcePlugin, EnvCheck, HookError, ImportReport, Source, Tab, UpdateInfo, BuiltinMod, ModCatalog, CatalogHit } from '../types'
19
20const PANE = 'mod-box-settings'
21// How Claude Code names this plugin's /config rows: <plugin>.<field>, for an installed plugin often
22// <plugin>@<marketplace>.<field>. Found from the list itself (see configRows); this is the first guess.
23let PREFIX = PLUGIN + '.'
24const STORE_LINKS = 'source-links'
25const STORE_PENDING = 'profile-pending'
26// The profile last applied, the mods the box has seen, and the imported mods the person trusts.
27const STORE_ACTIVE = 'active-profile'
28const STORE_SEEN = 'seen-mods'
29const STORE_TRUSTED = 'trusted-mods'
30const ROWS_PER_SOURCE = 12
31
32const tab = atom({ plugin: 'mod-box', key: 'mb_tab' } as const, 'mods' as Tab)
33const links = atom({ plugin: 'mod-box', key: 'mb_links' } as const, [] as string[])
34const sources = atom({ plugin: 'mod-box', key: 'mb_sources' } as const, null as Source[] | null)
35const loadedAt = atom({ plugin: 'mod-box', key: 'mb_loadedAt' } as const, 0)
36const busy = atom({ plugin: 'mod-box', key: 'mb_busy' } as const, null as string | null)
37const filter = atom({ plugin: 'mod-box', key: 'mb_filter' } as const, '')
38const open = atom({ plugin: 'mod-box', key: 'mb_open' } as const, [] as string[])
39const report = atom({ plugin: 'mod-box', key: 'mb_report' } as const, null as ImportReport | null)
40// The community mod catalogue: the last search and its hits.
41const modcat = atom({ plugin: 'mod-box', key: 'mb_modcat' } as const, null as ModCatalog | null)
42// Natively managed plugins (tools/native.mjs) and their details, read when a row opens.
43const natives = atom({ plugin: 'mod-box', key: 'mb_natives' } as const, null as NativePlugin[] | null)
44const builtinMods = atom({ plugin: 'mod-box', key: 'mb_builtins' } as const, null as BuiltinMod[] | null)
45const nativeInfo = atom({ plugin: 'mod-box', key: 'mb_nativeInfo' } as const, {} as Record<string, NativeInfo>)
46const expanded = atom({ plugin: 'mod-box', key: 'mb_expanded' } as const, [] as string[])
47// Mods that came in since the last session: marked "new" in the window for this session.
48const fresh = atom({ plugin: 'mod-box', key: 'mb_fresh' } as const, [] as string[])
49let version = ''
50const updates = atom({ plugin: 'mod-box', key: 'mb_updates' } as const, null as UpdateInfo[] | null)
51const usage = atom({ plugin: 'mod-box', key: 'mb_usage' } as const, {} as Record<string, ModUsage>)
52const errors = atom({ plugin: 'mod-box', key: 'mb_errors' } as const, [] as HookError[])
53const envChecks = atom({ plugin: 'mod-box', key: 'mb_envChecks' } as const, null as EnvCheck[] | null)
54const validation = atom({ plugin: 'mod-box', key: 'mb_validation' } as const, null as { ok: boolean; text: string; at: number } | null)
55const debugLines = atom({ plugin: 'mod-box', key: 'mb_debugLines' } as const, null as string[] | null)
56const updatedAt = atom({ plugin: 'mod-box', key: 'mb_updatedAt' } as const, 0)
57const diff = atom({ plugin: 'mod-box', key: 'mb_diff' } as const, null as { id: string; from: string; to: string; text: string } | null)
58
59// ---------------------------------------------------------------- language and options
60
61let lang: 'de' | 'en' = 'en'
62const t = (de: string, en: string) => (lang === 'en' ? en : de)
63const tx = (text: I18n | null | undefined) => (text ? (lang === 'en' ? text.en : text.de || text.en) : '')
64const label = (f: CatalogField | undefined, value: string) => f?.labels?.[lang]?.[value] ?? value
65
66// The core gets all options: switches, display per band mod, band layout and order.
67let opts: Record<string, unknown> = {}
68
69function toggleOn(toggle: string): boolean {
70  const v = opts[toggle]
71  if (typeof v === 'boolean') return v
72  return CATALOG.find(m => m.toggle === toggle)?.default ?? false
73}
74
75function setting(key: string): string {
76  const v = opts[key]
77  if (typeof v === 'string' && v !== '') return v
78  return String(SETTINGS.find(s => s.key === key)?.default ?? '')
79}
80
81export function showMode(toggle: string): 'band' | 'status' | 'off' {
82  const v = opts[toggle + 'Show']
83  return v === 'status' || v === 'off' ? v : 'band'
84}
85
86export function bandOrder(raw: string): string[] {
87  const list = raw.split(',').map(s => s.trim()).filter(x => BAND_IDS.includes(x))
88  return [...new Set([...list, ...BAND_IDS])]
89}
90
91export const register: Register = (on, options) => {
92  opts = (options ?? {}) as Record<string, unknown>
93  lang = opts.language === 'de' ? 'de' : 'en'
94
95  // Diagnosis: as the outermost hooks of the box these read next.trace after the box's own hooks ran
96  // and note every box hook that failed. Switch it off with the setting diagnose = off.
97  if (setting('diagnose') !== 'off') {
98    on('session.start', {}, async ($, e, next) => {
99      try {
100        const r = await next(e)
101        noteFailures('session.start', next.trace)
102        void flushErrors($).catch(() => {})
103        return r
104      } catch (err) {
105        noteThrown('session.start', err)
106        void flushErrors($).catch(() => {})
107        throw err
108      }
109    })
110    on('session.end', {}, async ($, e, next) => {
111      try {
112        const r = await next(e)
113        noteFailures('session.end', next.trace)
114        void flushErrors($).catch(() => {})
115        return r
116      } catch (err) {
117        noteThrown('session.end', err)
118        void flushErrors($).catch(() => {})
119        throw err
120      }
121    })
122    on('session.compact', {}, async ($, e, next) => {
123      try {
124        const r = await next(e)
125        noteFailures('session.compact', next.trace)
126        void flushErrors($).catch(() => {})
127        return r
128      } catch (err) {
129        noteThrown('session.compact', err)
130        void flushErrors($).catch(() => {})
131        throw err
132      }
133    })
134    on('turn.start', {}, async ($, e, next) => {
135      try {
136        const r = await next(e)
137        noteFailures('turn.start', next.trace)
138        void flushErrors($).catch(() => {})
139        return r
140      } catch (err) {
141        noteThrown('turn.start', err)
142        void flushErrors($).catch(() => {})
143        throw err
144      }
145    })
146    on('turn.complete', {}, async ($, e, next) => {
147      try {
148        const r = await next(e)
149        noteFailures('turn.complete', next.trace)
150        void flushErrors($).catch(() => {})
151        return r
152      } catch (err) {
153        noteThrown('turn.complete', err)
154        void flushErrors($).catch(() => {})
155        throw err
156      }
157    })
158    on('tool.call', {}, async ($, e, next) => {
159      try {
160        const r = await next(e)
161        noteFailures('tool.call', next.trace)
162        void flushErrors($).catch(() => {})
163        return r
164      } catch (err) {
165        noteThrown('tool.call', err)
166        void flushErrors($).catch(() => {})
167        throw err
168      }
169    })
170    on('prompt.submit', {}, async ($, e, next) => {
171      try {
172        const r = await next(e)
173        noteFailures('prompt.submit', next.trace)
174        void flushErrors($).catch(() => {})
175        return r
176      } catch (err) {
177        noteThrown('prompt.submit', err)
178        void flushErrors($).catch(() => {})
179        throw err
180      }
181    })
182    on('command.run', {}, async ($, e, next) => {
183      try {
184        const r = await next(e)
185        noteFailures('command.run', next.trace)
186        void flushErrors($).catch(() => {})
187        return r
188      } catch (err) {
189        noteThrown('command.run', err)
190        void flushErrors($).catch(() => {})
191        throw err
192      }
193    })
194    on('agent.spawn', {}, async ($, e, next) => {
195      try {
196        const r = await next(e)
197        noteFailures('agent.spawn', next.trace)
198        void flushErrors($).catch(() => {})
199        return r
200      } catch (err) {
201        noteThrown('agent.spawn', err)
202        void flushErrors($).catch(() => {})
203        throw err
204      }
205    })
206    on('config.set', {}, async ($, e, next) => {
207      try {
208        const r = await next(e)
209        noteFailures('config.set', next.trace)
210        void flushErrors($).catch(() => {})
211        return r
212      } catch (err) {
213        noteThrown('config.set', err)
214        void flushErrors($).catch(() => {})
215        throw err
216      }
217    })
218    on('ui.close', {}, async ($, e, next) => {
219      try {
220        const r = await next(e)
221        noteFailures('ui.close', next.trace)
222        void flushErrors($).catch(() => {})
223        return r
224      } catch (err) {
225        noteThrown('ui.close', err)
226        void flushErrors($).catch(() => {})
227        throw err
228      }
229    })
230    on('classic.SessionStart', {}, async ($, e, next) => {
231      try {
232        const r = await next(e)
233        noteFailures('classic.SessionStart', next.trace)
234        void flushErrors($).catch(() => {})
235        return r
236      } catch (err) {
237        noteThrown('classic.SessionStart', err)
238        void flushErrors($).catch(() => {})
239        throw err
240      }
241    })
242    on('classic.CwdChanged', {}, async ($, e, next) => {
243      try {
244        const r = await next(e)
245        noteFailures('classic.CwdChanged', next.trace)
246        void flushErrors($).catch(() => {})
247        return r
248      } catch (err) {
249        noteThrown('classic.CwdChanged', err)
250        void flushErrors($).catch(() => {})
251        throw err
252      }
253    })
254    on('classic.FileChanged', {}, async ($, e, next) => {
255      try {
256        const r = await next(e)
257        noteFailures('classic.FileChanged', next.trace)
258        void flushErrors($).catch(() => {})
259        return r
260      } catch (err) {
261        noteThrown('classic.FileChanged', err)
262        void flushErrors($).catch(() => {})
263        throw err
264      }
265    })
266    on('ui.render', { component: 'Pane' }, async ($, e, next) => {
267      try {
268        const r = await next(e)
269        noteFailures('ui.render', next.trace, e)
270        return r
271      } catch (err) {
272        noteThrown('ui.render', err, e)
273        throw err
274      }
275    })
276  }
277
278  // Band manager: the band mods hand over their displays (band()); here they are ordered and drawn
279  // compact or one line each, or sent to the status line. Upstream mods (Savvy Progress, Next Steps,
280  // Replay Theater, Blast Radius) still draw on their own, above.
281  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
282    openBand(e)
283    let rest
284    try {
285      rest = await next(e)
286    } catch (err) {
287      if (setting('diagnose') !== 'off') noteThrown('ui.render', err, e)
288      throw err
289    }
290    if (setting('diagnose') !== 'off') noteFailures('ui.render', next.trace, e)
291    const parts = takeBand(e).filter(p => toggleOn(p.id))
292    if (e.props.hasSurvey) return rest
293    const order = bandOrder(setting('bandOrder'))
294    parts.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id))
295    const status = parts.filter(p => showMode(p.id) === 'status').map(p => p.status)
296    try {
297      $.ui.status(status.length ? status.join('  ·  ') : undefined)
298    } catch {
299      // the status line is not reachable from here; the band still draws
300    }
301    const shown = parts.filter(p => showMode(p.id) === 'band')
302    if (shown.length === 0) return rest
303    const { Box, Text } = $.ui.resolve(e)
304    const isCompact = setting('bandLayout') !== 'lines'
305    const own = isCompact ? shown.filter(p => p.urgent) : shown
306    const calm = isCompact ? shown.filter(p => !p.urgent) : []
307    const withGaps = (actions: unknown[]) => actions.flatMap((a, i) => [<Text key={`gap-${i}`}>{'  '}</Text>, a])
308    return (
309      <Box flexDirection="column">
310        {rest}
311        {own.map(p => (
312          <Box key={`band-${p.id}`} flexDirection="row" paddingX={1}>
313            <Box flexShrink={1}>{p.line as any}</Box>
314            {withGaps(p.actions) as any}
315          </Box>
316        ))}
317        {calm.length ? (
318          <Box key="band-calm" flexDirection="row" paddingX={1}>
319            <Text wrap="truncate-end">
320              {calm.flatMap((p, i) => (i === 0 ? [p.short] : [<Text key={`sep-${i}`} dimColor>{'  │  '}</Text>, p.short])) as any}
321            </Text>
322            {withGaps(calm.flatMap(p => p.actions)) as any}
323          </Box>
324        ) : null}
325      </Box>
326    )
327  })
328
329  on('session.start', {}, async ($, e, next) => {
330    await $.command.register({
331      name: 'mod-box',
332      description: t('Mod-Box: Mods schalten, Profile, Quellen', 'Mod Box: switch mods, profiles, sources'),
333      argumentHint: '[mods|profiles|sources|diag]',
334    })
335    version = await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).then(x => String(JSON.parse(String(x)).version ?? '')).catch(() => '')
336    const saved = await $.store.get(STORE_LINKS)
337    if (Array.isArray(saved)) await update($, links, () => saved.filter((x): x is string => typeof x === 'string'))
338    // A profile set over several reloads continues here.
339    void continueProfile($).catch(() => {})
340    void adoptNewMods($).catch(() => {})
341    void detectOs($).catch(() => {})
342    $.clock.every(4000, () => void flushErrors($).catch(() => {}))
343    return next(e)
344  })
345
346  on('command.run', { command: 'mod-box' }, async ($, e) => {
347    const want = String(e.args ?? '').trim().toLowerCase()
348    // Diagnosis aid: which /config rows Claude Code hands this plugin.
349    if (want === 'keys') {
350      const list = await $.config.list().catch((err: unknown) => [{ key: `Fehler: ${String(err)}` }] as any[])
351      const opts2 = Object.keys(opts).slice(0, 40).join(', ')
352      return { text: `config.list: ${list.length} rows\n${list.map((r: any) => `${r.key} = ${JSON.stringify(r.value)} (${r.kind ?? '?'}, ${r.provider?.plugin ?? '?'}${r.isLocked ? ', gesperrt' : ''})${r.options ? ` [${r.options.join(' | ')}]` : ''}`).join('\n')}\n\noptions: ${opts2 || '(none)'}` }
353    }
354    const alias: Record<string, Tab> = { mods: 'mods', profiles: 'profiles', profile: 'profiles', sources: 'sources', quellen: 'sources', diagnose: 'diag', diag: 'diag' }
355    if (alias[want]) await switchTab($, alias[want])
356    await $.ui.open({ id: PANE, title: 'Mod Box', focus: true, closeOnEscape: true })
357    const now = await read($, tab)
358    if (now === 'sources' && (await read($, sources)) === null) void loadCatalog($, false)
359    if ((await read($, envChecks)) === null) void runEnvChecks($).catch(() => {})
360    if ((await read($, natives)) === null) void loadNatives($).catch(() => {})
361    if ((await read($, builtinMods)) === null) void loadBuiltins($).catch(() => {})
362    return { text: t('Mod-Box geöffnet (Esc schließt).', 'Mod Box opened (Esc closes).') }
363  })
364
365  on('ui.render', { component: 'Pane', requestId: DIFF_PANE }, async ($, e) => {
366    const { Box, Text, Code } = $.ui.resolve(e) as any
367    const d = await read($, diff)
368    if (!d) return <Text dimColor>{t('Kein Diff.', 'No diff.')}</Text>
369    return (
370      <Box flexDirection="column" paddingX={1}>
371        <Text dimColor wrap="wrap">{t(`Was sich im Original von ${d.id} seit unserem Stand geändert hat (${d.from} → ${d.to}). Eigene Anpassungen sind hier nicht enthalten; Übernehmen arbeitet das in sie ein.`, `What changed in the original of ${d.id} since our copy (${d.from} → ${d.to}). Own changes are not shown; Apply merges this into them.`)}</Text>
372        {d.text ? (Code ? <Code source={d.text} format="diff" /> : <Text>{d.text}</Text>) : <Text dimColor>{t('(leer)', '(empty)')}</Text>}
373      </Box>
374    )
375  })
376
377  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
378    const ui = $.ui.resolve(e) as any
379    const { Box, Text, Button } = ui
380    const current = await read($, tab)
381    const rows = await configRows($)
382    const isBusy = await read($, busy)
383    const onCount = CATALOG.filter(m => rows.get(m.toggle)?.value === true).length
384
385    const tabs: Array<[Tab, string]> = [
386      ['mods', `🧩 ${t('Mods', 'Mods')}`],
387      ['profiles', `◐ ${t('Profile', 'Profiles')}`],
388      ['sources', `⇩ ${t('Quellen', 'Sources')}`],
389      ['diag', `🩺 ${t('Zustand', 'Health')}`],
390    ]
391
392    const columns = e.props.bodyColumns ?? 80
393    const trusted = await trustedList($)
394    let body
395    if (current === 'profiles') body = drawProfiles($, ui, rows, columns)
396    else if (current === 'sources') body = <Box flexDirection="column">{await drawUpdates($, ui)}{await drawSources($, ui, columns)}</Box>
397    else if (current === 'diag') body = <Box flexDirection="column">{await drawUsage($, ui)}{await drawDiagnosis($, ui)}</Box>
398    else body = drawMods($, ui, rows, await drawNotes($, ui), await read($, usage), await read($, expanded), columns, { ...(await here($, e.surface)), trusted, fresh: await read($, fresh) }, drawNativeCard($, ui, await read($, natives), await read($, nativeInfo), await read($, expanded), (await $.session.usage().catch(() => null))?.startedAt ?? 0, await read($, builtinMods)))
399
400    const storedId = await activeProfileId($)
401    const matches = (p: (typeof PROFILES)[number]) => profileSet(p, trusted, rows).every(([k, v]) => (rows.get(k)?.value === true) === v)
402    const stored = PROFILES.find(p => p.id === storedId)
403    // The stored profile while it still fits; else the profile the switches match exactly; else
404    // the stored one marked "(changed)".
405    const exact = stored && matches(stored) ? stored : PROFILES.find(matches)
406    const active = exact ?? stored
407    const changed = !exact && !!stored
408    const r = await read($, report)
409    return (
410      <Box flexDirection="column" paddingX={1}>
411        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
412          <Text>
413            <Text bold color="claude">{'🧰 Mod Box'}</Text>
414            <Text color="inactive" dimColor>{version ? `  v${version}` : ''}</Text>
415          </Text>
416          <Text>
417            {isBusy ? <Text color="suggestion">{`⏳ ${isBusy}    `}</Text> : null}
418            {active ? <Text color="inactive">{`${PROFILE_ICON[active.id] ?? '◎'} ${tx(active.label)}${changed ? t(' (angepasst)', ' (changed)') : ''}    `}</Text> : null}
419            <Text color="success" bold>{`${onCount}`}</Text>
420            <Text color="inactive">{t(` / ${CATALOG.length} an`, ` / ${CATALOG.length} on`)}</Text>
421          </Text>
422        </Box>
423        <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
424          {tabs.map(([k, text]) => (
425            <Button key={`tab-${k}`} label={text} variant={current === k ? 'primary' : 'secondary'} plain={current === k ? undefined : true} onPress={() => void switchTab($, k)} />
426          ))}
427        </Box>
428        {r ? drawReport($, ui, r) : null}
429        <Box flexDirection="column" marginTop={1}>
430          {body}
431        </Box>
432        <Text color="inactive" dimColor>
433          {t('Klick oder Enter schaltet · ⚙ zeigt Einstellungen · Esc schließt', 'Click or Enter switches · ⚙ shows settings · Esc closes')}
434        </Text>
435      </Box>
436    )
437  })
438}
439
440// ---------------------------------------------------------------- Mods
441
442/** The row prefix of this plugin in a /config list, found by a field it surely has. */
443export function findPrefix(keys: string[], plugin: string, fields: string[]): string | null {
444  for (const f of fields) {
445    const hit = keys.find(k => k.endsWith('.' + f) && (k.startsWith(plugin + '.') || k.startsWith(plugin + '@')))
446    if (hit) return hit.slice(0, hit.length - f.length)
447  }
448  return null
449}
450
451async function configRows($: EngineInterface): Promise<Map<string, ConfigRow>> {
452  const list = await $.config.list().catch(() => [] as ConfigRow[])
453  const found = findPrefix(list.map(r => r.key), PLUGIN, ['blastRadius', 'language', 'bandLayout', ...CATALOG.map(m => m.toggle)])
454  if (found) PREFIX = found
455  const own = list.filter(r => r.key.startsWith(PREFIX))
456  // Some hosts (the desktop app) list no rows for a plugin's userConfig. Then the window builds them
457  // from the catalog and the options it was loaded with, and writes changes to settings.json itself.
458  SYNTHETIC = own.length === 0
459  return new Map((SYNTHETIC ? syntheticRows() : own).map(r => [r.key.slice(PREFIX.length), r] as const))
460}
461
462let SYNTHETIC = false
463
464export function syntheticRows(): ConfigRow[] {
465  const provider = { kind: 'plugin', name: PLUGIN } as any
466  const field = (f: CatalogField): ConfigRow => {
467    const labelled = Object.keys(f.labels?.de ?? f.labels?.en ?? {})
468    const choices = labelled.length ? labelled : (f.options ?? [])
469    const value = opts[f.key] ?? f.default
470    const kind = f.type === 'number' ? 'number' : f.type === 'boolean' || typeof f.default === 'boolean' ? 'boolean' : choices.length ? 'choice' : 'text'
471    return { key: PREFIX + f.key, label: tx(f.title), kind, value: value as any, ...(choices.length ? { options: choices } : {}), provider, isLocked: false } as ConfigRow
472  }
473  const out = SETTINGS.map(field)
474  for (const m of CATALOG) {
475    out.push({ key: PREFIX + m.toggle, label: tx(m.title), kind: 'boolean', value: (typeof opts[m.toggle] === 'boolean' ? opts[m.toggle] : m.default) as any, provider, isLocked: false } as ConfigRow)
476    for (const p of m.params) out.push(field(p))
477  }
478  return out
479}
480
481// The key settings.json keeps this plugin's options under: <name>@inline when loaded from a folder,
482// else the installed id already there.
483export function configId(keys: string[], root: string): string {
484  if (!/[\\/]plugins[\\/]cache[\\/]/.test(root)) return PLUGIN + '@inline'
485  return keys.find(k => k.startsWith(PLUGIN + '@') && k !== PLUGIN + '@inline') ?? PLUGIN + '@' + PLUGIN
486}
487
488async function writeOptions($: EngineInterface, values: Record<string, unknown>) {
489  const path = `${await home($)}/.claude/settings.json`
490  try {
491    const s = JSON.parse(String(await $.fs.read(path))) as Record<string, any>
492    s.pluginConfigs = s.pluginConfigs ?? {}
493    const id = configId(Object.keys(s.pluginConfigs), $.plugin.root)
494    const entry = (s.pluginConfigs[id] = s.pluginConfigs[id] ?? {})
495    entry.options = { ...(entry.options ?? {}), ...values }
496    await $.fs.write(path, JSON.stringify(s, null, 2) + '\n')
497  } catch (err) {
498    $.ui.toast(`${t('Nicht geändert', 'Not changed')}: ${String(err)}`)
499    return
500  }
501  opts = { ...opts, ...values }
502  $.ui.toast(Object.entries(values).map(([k, v]) => `${k} = ${String(v)}`).join(', '))
503  redraw($)
504  // The new options reach the mods with a reload of the box.
505  try {
506    await $.command.run({ command: 'reload-plugins' })
507  } catch {
508    await $.fs.write(`${$.plugin.root}/hooks/reload.stamp`, String(Date.now())).catch(() => {})
509  }
510}
511
512// Each group has an icon and an accent from the person's theme, so the window follows their colors.
513const GROUP_ICON: Record<string, string> = { guards: '🛡', display: '👁', context: '🧠', watchers: '🔔', imported: '📦' }
514const GROUP_ACCENT: Record<string, string> = { guards: 'permission', display: 'suggestion', context: 'planMode', watchers: 'warning', imported: 'merged' }
515
516function toggleExpanded($: EngineInterface, key: string) {
517  void update($, expanded, list => (list.includes(key) ? list.filter(x => x !== key) : [...list, key]))
518}
519
520function drawParam($: EngineInterface, ui: any, rows: Map<string, ConfigRow>, f: CatalogField, isActive: boolean, indent = 4) {
521  const { Box, Text, Button, Select, Input } = ui
522  const r = rows.get(f.key)
523  if (!r) return null
524  const name = tx(f.title).replace(/^[^:]+:\s*/, '')
525  const value = r.value == null ? '' : String(r.value)
526  const control =
527    r.isLocked ? <Text dimColor>{`${label(f, value)} (${t('gesperrt', 'locked')})`}</Text>
528    : r.kind === 'choice' && r.options && Select ? (
529      <Select key={`sel-${f.key}`} options={r.options.map(o => ({ value: o, label: label(f, o) }))} value={value} onSelect={(v: string) => void set($, r.key, v)} />
530    )
531    : r.kind === 'boolean' ? (
532      <Button key={`b-${f.key}`} plain onPress={() => void set($, r.key, r.value !== true)}>
533        <Text color={r.value === true ? 'success' : 'inactive'}>{r.value === true ? '● ' : '○ '}</Text>
534        {r.value === true ? t('an', 'on') : t('aus', 'off')}
535      </Button>
536    )
537    : Input ? (
538      <Input key={`in-${f.key}`} value={value} placeholder={t('leer = Standard', 'empty = default')} submitLabel={t('Übernehmen', 'Apply')} onSubmit={(v: string) => void set($, r.key, r.kind === 'number' ? Number(v) : v)} />
539    )
540    : <Text>{value}</Text>
541  return (
542    <Box key={`p-${f.key}`} flexDirection="row" alignItems="center" paddingLeft={indent}>
543      <Box width={22} flexShrink={0}>
544        <Text color="inactive" dimColor={!isActive} wrap="truncate-end">{name}</Text>
545      </Box>
546      {control}
547    </Box>
548  )
549}
550
551/** The first clause of a description: what a closed row shows. */
552export function tagline(text: string): string {
553  // A parenthesis only ends the tagline when enough comes before it ("Such-Subagenten (Explore) …").
554  const stop = text.search(/;\s|\.\s/)
555  const paren = text.search(/\s\(/)
556  const cut = paren > 40 && (stop < 0 || paren < stop) ? paren : stop
557  return (cut > 12 ? text.slice(0, cut) : text).replace(/[.;]\s*$/, '')
558}
559
560function card(ui: any, key: string, accent: string, head: unknown, children: unknown) {
561  const { Box } = ui
562  return (
563    <Box key={key} flexDirection="column" borderStyle="round" borderColor={accent} paddingX={1} marginBottom={1}>
564      {head as any}
565      {children as any}
566    </Box>
567  )
568}
569
570// What a mod may do (read from its source at build time): short marks for the row, words for ⚙.
571type ReachInfo = (typeof CATALOG)[number]['reach']
572export function reachMarks(r: ReachInfo): string {
573  return [r.reads ? '👁' : '', r.writes ? '✎' : '', r.runs ? '▶' : '', r.network ? '🌐' : '', r.settings ? '🔧' : ''].filter(Boolean).join(' ')
574}
575export function reachText(r: ReachInfo): string {
576  const runs = r.programs.length ? `${t('startet Programme', 'runs programs')} (${r.programs.slice(0, 6).join(', ')}${r.programs.length > 6 ? ' …' : ''})` : t('startet Programme', 'runs programs')
577  const parts = [
578    r.reads ? t('👁 liest Dateien oder das Gespräch', '👁 reads files or the transcript') : '',
579    r.writes ? t('✎ schreibt Dateien, das Gespräch oder den Prompt', '✎ writes files, the transcript or the prompt') : '',
580    r.runs ? `▶ ${runs}` : '',
581    r.network ? t('🌐 geht ins Netz', '🌐 goes online') : '',
582    r.settings ? t('🔧 ändert Einstellungen', '🔧 changes settings') : '',
583    r.model ? t('✦ ruft das Modell auf', '✦ calls the model') : '',
584  ].filter(Boolean)
585  return parts.length ? parts.join(' · ') : t('nur Anzeige und Hooks, kein Dateizugriff, keine Programme, kein Netz', 'display and hooks only: no files, no programs, no network')
586}
587
588function drawMods($: EngineInterface, ui: any, rows: Map<string, ConfigRow>, notes: unknown, used: Record<string, ModUsage>, open: string[], columns: number, env: Here & { trusted: string[]; fresh: string[] }, nativeCard: unknown = null) {
589  const { Box, Text, Button } = ui
590  if (rows.size === 0) return <Text dimColor>{t('Keine Einstellungen gefunden. Ist die Mod Box aktiviert?', 'No settings found. Is the Mod Box enabled?')}</Text>
591
592  const groups: string[] = []
593  for (const m of CATALOG) if (!groups.includes(m.group)) groups.push(m.group)
594  const known = new Set([...SETTINGS.map(s => s.key), ...CATALOG.flatMap(m => [m.toggle, ...m.params.map(p => p.key)])])
595  const rest = [...rows.keys()].filter(k => !known.has(k))
596
597  const modRow = (m: (typeof CATALOG)[number]) => {
598    const r = rows.get(m.toggle)
599    if (!r) return null
600    const isOn = r.value === true
601    const isOpen = open.includes(m.toggle)
602    const hasMore = true
603    const issues = compatIssues(m.compat, env)
604    const isTrusted = !m.imported || env.trusted.includes(m.toggle)
605    const isNew = env.fresh.includes(m.toggle)
606    return (
607      <Box key={`g-${m.toggle}`} flexDirection="column" marginTop={isOpen ? 1 : 0} marginBottom={isOpen ? 1 : 0}>
608        <Box flexDirection="row" alignItems="center" justifyContent="space-between">
609          <Box flexDirection="row" alignItems="center" flexShrink={1}>
610            <Button key={`t-${m.toggle}`} plain onPress={() => void switchMod($, m.toggle, r.key, !isOn)}>
611              <Text color={isOn ? 'success' : 'inactive'}>{isOn ? '●' : '○'}</Text>
612              {' '}
613              <Text bold={isOn} color={isOn ? 'text' : 'inactive'}>{tx(m.title)}</Text>
614            </Button>
615            {m.costs ? <Text color="warning">{' 💰'}</Text> : null}
616            {m.imported ? <Text>{isTrusted ? ' 📦' : ' 🔒'}</Text> : null}
617            {isNew ? <Text color="claude" bold>{t('  neu', '  new')}</Text> : null}
618            {issues.length ? <Text color="warning">{`  ⚠ ${tx(issues[0]!)}`}</Text> : null}
619            {reachMarks(m.reach) ? <Text color={m.reach.network || m.reach.runs ? 'warning' : 'inactive'} dimColor>{`  ${reachMarks(m.reach)}`}</Text> : null}
620            {m.commands.length ? <Text color="suggestion" dimColor>{`  ${m.commands[0]!.name.split(' ')[0]}`}</Text> : null}
621            {used[m.toggle] ? <Text color="warning" dimColor>{`  ${usageText(used[m.toggle]!)}`}</Text> : null}
622          </Box>
623          {hasMore ? <Button key={`exp-${m.toggle}`} label={isOpen ? '▾' : '⚙'} plain onPress={() => toggleExpanded($, m.toggle)} /> : null}
624        </Box>
625        <Box paddingLeft={2}>
626          <Text color="inactive" dimColor wrap={isOpen ? 'wrap' : 'truncate-end'}>{isOpen ? tx(m.description) : tagline(tx(m.description))}</Text>
627        </Box>
628        {isOpen ? (
629          <Box flexDirection="column" paddingLeft={2}>
630            <Text color="inactive" wrap="wrap">{`${t('Läuft auf', 'Runs on')}: ${compatText(m.compat)}`}</Text>
631            <Text color="inactive" wrap="wrap">{`${t('Darf', 'May')}: ${reachText(m.reach)}`}</Text>
632            {issues.map((x, i) => (
633              <Text key={`iss-${m.toggle}-${i}`} color="warning" wrap="wrap">{`⚠ ${tx(x)}`}</Text>
634            ))}
635            {!isTrusted ? (
636              <Box flexDirection="row" columnGap={2}>
637                <Text color="warning" wrap="wrap">{t('🔒 Fremder Code, noch nicht vertraut: Profile schalten ihn nicht ein.', '🔒 Foreign code, not trusted yet: profiles do not switch it on.')}</Text>
638                <Button key={`trust-${m.toggle}`} label={t('Vertrauen', 'Trust')} plain onPress={() => void trustMod($, m.toggle)} />
639              </Box>
640            ) : null}
641          </Box>
642        ) : null}
643        {isOpen && m.commands.length ? (
644          <Box flexDirection="column" paddingLeft={2}>
645            {m.commands.map(c => (
646              <Text key={`cmd-${m.toggle}-${c.name}`}>
647                <Text color="suggestion">{c.name}</Text>
648                <Text color="inactive">{`  ${tx(c.text)}`}</Text>
649              </Text>
650            ))}
651          </Box>
652        ) : null}
653        {isOpen && m.costs ? (
654          <Box paddingLeft={2}>
655            <Text color="warning" wrap="wrap">{`💰 ${tx(m.costs)}`}</Text>
656          </Box>
657        ) : null}
658        {isOpen ? (
659          <Box flexDirection="column">
660            {m.params.map(p => drawParam($, ui, rows, p, isOn, 2))}
661            {m.toggle === 'secretGuard' || m.imported ? (
662              <Box flexDirection="row" paddingLeft={2} gap={2}>
663                {m.toggle === 'secretGuard' ? <Button key="sg-allow" label={t('Ausnahmen verwalten', 'Manage exceptions')} plain onPress={() => void $.command.run({ command: 'secret-allow' }).catch(() => $.ui.toast(t('Bitte /secret-allow eingeben.', 'Please type /secret-allow.')))} /> : null}
664                {m.imported ? <Button key={`rm-${m.id}`} label={t('Aus der Mod Box entfernen', 'Remove from the Mod Box')} plain onPress={() => void removeMod($, m.id, tx(m.title))} /> : null}
665              </Box>
666            ) : null}
667          </Box>
668        ) : null}
669      </Box>
670    )
671  }
672
673  const groupCard = (g: string) => {
674    const mods = CATALOG.filter(m => m.group === g)
675    const on = mods.filter(m => rows.get(m.toggle)?.value === true).length
676    const accent = GROUP_ACCENT[g] ?? 'subtle'
677    return card(
678      ui,
679      `grp-${g}`,
680      accent,
681      <Box flexDirection="row" justifyContent="space-between">
682        <Text bold color={accent}>{`${GROUP_ICON[g] ?? '•'} ${tx(GROUPS[g]) || g}`}</Text>
683        <Text color={on ? 'success' : 'inactive'}>{`${on}/${mods.length}`}</Text>
684      </Box>,
685      <Box flexDirection="column">{mods.map(modRow)}</Box>,
686    )
687  }
688
689  // General: language and band layout as selects, the band order in one line with "change".
690  const orderOpen = open.includes('bandOrder')
691  const general = card(
692    ui,
693    'card-general',
694    'subtle',
695    <Text bold color="inactive">{`⚙ ${t('Allgemein', 'General')}`}</Text>,
696    <Box flexDirection="column">
697      {SETTINGS.filter(s => s.key !== 'bandOrder').map(s => drawParam($, ui, rows, s, true, 0))}
698      <Box flexDirection="row" alignItems="center">
699        <Box width={22} flexShrink={0}>
700          <Text color="inactive">{t('Band-Reihenfolge', 'Band order')}</Text>
701        </Box>
702        <Button key="ord-toggle" plain onPress={() => toggleExpanded($, 'bandOrder')}>
703          {orderOpen
704            ? t('fertig', 'done')
705            : bandOrder(rows.get('bandOrder')?.value == null ? setting('bandOrder') : String(rows.get('bandOrder')!.value))
706                .map(id => shortTitle(id))
707                .join(' › ')}
708        </Button>
709      </Box>
710      {orderOpen ? drawOrder($, ui, rows) : null}
711    </Box>,
712  )
713  const extra = rest.length
714    ? card(
715        ui,
716        'card-rest',
717        'subtle',
718        <Text bold>{t('Weitere', 'More')}</Text>,
719        <Box flexDirection="column">{rest.map(k => drawParam($, ui, rows, { key: k, title: { en: rows.get(k)?.label ?? k, de: rows.get(k)?.label ?? k }, description: { en: '', de: '' }, labels: {}, options: null, type: 'string', multiple: false, default: '' }, true, 0))}</Box>,
720      )
721    : null
722
723  // Wide windows get two columns; the groups are spread so both columns end about level.
724  const cards = [...groups.map(g => ({ el: groupCard(g), size: 2 + CATALOG.filter(m => m.group === g).length * 2 + (open.some(k => CATALOG.find(m => m.toggle === k)?.group === g) ? 6 : 0) })), { el: general, size: 6 }]
725  if (extra) cards.push({ el: extra, size: 2 + rest.length })
726  if (nativeCard) cards.push({ el: nativeCard as any, size: 6 })
727  let body
728  if (columns >= 110) {
729    const cols: Array<{ els: unknown[]; size: number }> = [{ els: [], size: 0 }, { els: [], size: 0 }]
730    for (const c of cards) {
731      const target = cols[0]!.size <= cols[1]!.size ? cols[0]! : cols[1]!
732      target.els.push(c.el)
733      target.size += c.size
734    }
735    body = (
736      <Box flexDirection="row" columnGap={2}>
737        {cols.map((c, i) => (
738          <Box key={`col-${i}`} flexDirection="column" flexGrow={1} flexShrink={1} width="50%">
739            {c.els as any}
740          </Box>
741        ))}
742      </Box>
743    )
744  } else body = <Box flexDirection="column">{cards.map(c => c.el) as any}</Box>
745
746  return (
747    <Box flexDirection="column">
748      {notes as any}
749      {body}
750    </Box>
751  )
752}
753
754// One quiet line above the cards when something wants attention: updates waiting, tokens spent.
755async function drawNotes($: EngineInterface, ui: any) {
756  const { Box, Text, Button } = ui
757  const list = await read($, updates)
758  const pending = (list ?? []).filter(u => u.status === 'update').length
759  const s = sumBook(await read($, usage))
760  if (!pending && !s.calls) return null
761  return (
762    <Box key="notes" flexDirection="row" columnGap={3} marginBottom={1}>
763      {pending ? (
764        <Button key="note-upd" plain onPress={() => void switchTab($, 'sources')}>
765          <Text color="suggestion">{`⬆ ${pending} ${t(pending === 1 ? 'Update' : 'Updates', pending === 1 ? 'update' : 'updates')}`}</Text>
766          <Text dimColor>{t(' unter Quellen', ' under Sources')}</Text>
767        </Button>
768      ) : null}
769      {s.calls ? (
770        <Button key="note-usage" plain onPress={() => void switchTab($, 'diag')}>
771          <Text color="warning">{`💰 ${usageText(s)}`}</Text>
772          <Text dimColor>{t(' in dieser Sitzung', ' this session')}</Text>
773        </Button>
774      ) : null}
775    </Box>
776  )
777}
778
779function shortTitle(toggle: string): string {
780  return (tx(CATALOG.find(m => m.toggle === toggle)?.title) || toggle).replace(/\s*\(.*\)$/, '')
781}
782
783// The band order as a list with arrows; writes the comma list into the bandOrder setting.
784function drawOrder($: EngineInterface, ui: any, rows: Map<string, ConfigRow>) {
785  const { Box, Text, Button } = ui
786  const row = rows.get('bandOrder')
787  const order = bandOrder(row?.value == null ? setting('bandOrder') : String(row.value))
788  const title = (toggle: string) => tx(CATALOG.find(m => m.toggle === toggle)?.title) || toggle
789  const move = (i: number, d: number) => {
790    const next = [...order]
791    const j = i + d
792    if (j < 0 || j >= next.length) return
793    ;[next[i], next[j]] = [next[j]!, next[i]!]
794    void set($, PREFIX + 'bandOrder', next.join(','))
795  }
796  return (
797    <Box key="band-order" flexDirection="column" paddingLeft={2}>
798      {order.map((id, i) => (
799        <Box key={`ord-${id}`} flexDirection="row" paddingLeft={2}>
800          <Button key={`ord-up-${id}`} label="▲" plain onPress={() => move(i, -1)} />
801          <Text>{' '}</Text>
802          <Button key={`ord-down-${id}`} label="▼" plain onPress={() => move(i, 1)} />
803          <Text dimColor={!toggleOn(id) || showMode(id) !== 'band'}>{`  ${i + 1}. ${title(id)}`}</Text>
804          {!toggleOn(id) ? <Text dimColor>{`  (${t('aus', 'off')})`}</Text> : showMode(id) !== 'band' ? <Text dimColor>{`  (${showMode(id) === 'status' ? t('Statuszeile', 'status line') : t('ausgeblendet', 'hidden')})`}</Text> : null}
805        </Box>
806      ))}
807    </Box>
808  )
809}
810
811async function set($: EngineInterface, key: string, value: string | number | boolean) {
812  if (SYNTHETIC && key.startsWith(PREFIX)) return writeOptions($, { [key.slice(PREFIX.length)]: value })
813  try {
814    const r = await $.config.set({ key, value })
815    if (r.deny !== undefined) $.ui.toast(`${t('Nicht geändert', 'Not changed')}: ${r.deny}`)
816    else $.ui.toast(`${key.startsWith(PREFIX) ? key.slice(PREFIX.length) : key} = ${String(value)}`)
817  } catch (err) {
818    $.ui.toast(`${t('Nicht geändert', 'Not changed')}: ${String(err)}`)
819  }
820  redraw($)
821}
822
823function redraw($: EngineInterface) {
824  try {
825    $.ui.invalidate('ui.render')
826  } catch {
827    // The box is reloading; the new load draws the window anyway.
828  }
829}
830
831async function switchTab($: EngineInterface, k: Tab) {
832  await update($, tab, () => k)
833  if (k === 'sources' && (await read($, sources)) === null) void loadCatalog($, false)
834  if (k === 'diag' && (await read($, envChecks)) === null) void runEnvChecks($)
835}
836
837// ---------------------------------------------------------------- Profiles
838
839const PROFILE_ICON: Record<string, string> = { default: '◎', lean: '🍃', guards: '🛡', all: '✦' }
840
841function drawProfiles($: EngineInterface, ui: any, rows: Map<string, ConfigRow>, columns = 80) {
842  // As many cards in a row as fit, never narrower than 26 cells.
843  const perRow = Math.max(1, Math.min(PROFILES.length, Math.floor((columns - 2) / 27)))
844  const width = Math.max(26, Math.floor((columns - 2 - (perRow - 1)) / perRow))
845  const { Box, Text, Button } = ui
846  return (
847    <Box flexDirection="column">
848      <Text dimColor>{t('Ein Profil setzt alle Schalter auf einmal; Parameter bleiben, wie sie sind.', 'A profile sets all switches at once; parameters stay as they are.')}</Text>
849      <Box flexDirection="row" flexWrap="wrap" gap={1} marginTop={1}>
850        {PROFILES.map(p => {
851          const isActive = p.set.every(([k, v]) => (rows.get(k)?.value === true) === v)
852          const on = p.set.filter(([, v]) => v).map(([k]) => shortTitle(k))
853          return (
854            <Box key={`prof-${p.id}`} flexDirection="column" borderStyle="round" borderColor={isActive ? 'success' : 'subtle'} paddingX={1} width={width}>
855              <Box flexDirection="row" justifyContent="space-between">
856                <Text bold>{`${PROFILE_ICON[p.id] ?? '•'}  ${tx(p.label)}`}</Text>
857                <Text dimColor>{`${on.length}/${p.set.length}`}</Text>
858              </Box>
859              <Text dimColor wrap="wrap">{on.length ? on.join(' · ') : t('alle aus', 'all off')}</Text>
860              <Box marginTop={1}>
861                {isActive ? (
862                  <Text color="success">{`● ${t('aktiv', 'active')}`}</Text>
863                ) : (
864                  <Button key={`prof-b-${p.id}`} label={t('Anwenden', 'Apply')} variant="primary" onPress={() => void applyProfile($, p.id)} />
865                )}
866              </Box>
867            </Box>
868          )
869        })}
870      </Box>
871    </Box>
872  )
873}
874
875async function applyProfile($: EngineInterface, id: string) {
876  const p = PROFILES.find(x => x.id === id)
877  if (!p) return
878  await $.store.set(STORE_ACTIVE, p.id)
879  if ((await read($, natives))?.some(n => n.managed && n.trusted)) await nativeRun($, ['profile', p.id], t('Profil für native Plugins …', 'Profile for native plugins …'))
880  const values = profileSet(p, await trustedList($), await configRows($))
881  if (SYNTHETIC || (await configRows($), SYNTHETIC)) return writeOptions($, Object.fromEntries(values))
882  await $.store.set(STORE_PENDING, values.map(([k, v]) => [k, v]))
883  $.ui.toast(t(`Profil „${tx(p.label)}“ wird gesetzt …`, `Applying profile "${tx(p.label)}" …`))
884  await continueProfile($)
885}
886
887// Every change reloads the box; what is still pending sits in the store and is continued after the
888// reload (session.start) until nothing differs any more.
889async function continueProfile($: EngineInterface) {
890  for (let guard = 0; guard < 50; guard++) {
891    const pending = await $.store.get(STORE_PENDING)
892    if (!Array.isArray(pending) || pending.length === 0) return
893    const rows = await configRows($)
894    const idx = pending.findIndex(([k, v]) => rows.has(String(k)) && rows.get(String(k))!.value !== v)
895    if (idx < 0) {
896      await $.store.delete(STORE_PENDING)
897      $.ui.toast(t('Profil gesetzt.', 'Profile applied.'))
898      redraw($)
899      return
900    }
901    const [k, v] = pending[idx] as [string, boolean]
902    await $.store.set(STORE_PENDING, pending.filter((_, i) => i !== idx))
903    const r = await $.config.set({ key: PREFIX + k, value: v }).catch(() => null)
904    if (r && r.deny !== undefined) $.ui.toast(`${k}: ${r.deny}`)
905  }
906}
907
908// ---------------------------------------------------------------- Profiles for new mods, trust, compatibility
909
910/** A profile's switches, with imported mods nobody trusted yet kept off. */
911// What a profile sets: its rule, but foreign mods only once trusted, and no mod while one of its
912// required options is still empty (it would start without what it needs).
913function profileSet(p: (typeof PROFILES)[number], trusted: string[], rows?: Map<string, { value: unknown }>): Array<[string, boolean]> {
914  const unset = (k: string) => {
915    const v = rows?.get(k)?.value
916    return rows !== undefined && (v === undefined || v === null || v === '')
917  }
918  return p.set.map(([k, v]) => {
919    const m = CATALOG.find(x => x.toggle === k)
920    return [k, v && (!m?.imported || trusted.includes(k)) && !(m?.needs ?? []).some(unset)]
921  })
922}
923
924/** The profile applied last; "default" until one is applied in the window. */
925async function activeProfileId($: EngineInterface): Promise<string> {
926  const v = await $.store.get(STORE_ACTIVE).catch(() => null)
927  return typeof v === 'string' ? v : 'default'
928}
929
930async function trustedList($: EngineInterface): Promise<string[]> {
931  const v = await $.store.get(STORE_TRUSTED).catch(() => null)
932  return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
933}
934
935async function trustMod($: EngineInterface, toggle: string) {
936  const list = await trustedList($)
937  if (!list.includes(toggle)) await $.store.set(STORE_TRUSTED, [...list, toggle])
938  // From now on the active profile decides about it, as about every other mod.
939  const activeId = await activeProfileId($)
940  const p = PROFILES.find(x => x.id === activeId)
941  const want = p?.set.find(([k]) => k === toggle)?.[1]
942  if (want !== undefined) await setMany($, { [toggle]: want })
943  else redraw($)
944}
945
946/** Switching an imported mod on by hand counts as trusting it. */
947async function switchMod($: EngineInterface, toggle: string, key: string, value: boolean) {
948  const m = CATALOG.find(x => x.toggle === toggle)
949  if (value && m?.needs.length) {
950    const rows = await configRows($)
951    const unset = m.needs.filter(k => {
952      const v = rows.get(k)?.value
953      return v === undefined || v === null || v === ''
954    })
955    if (unset.length) {
956      $.ui.toast(t(`Erst setzen: ${unset.join(', ')} (⚙ beim Mod).`, `Set these first: ${unset.join(', ')} (⚙ at the mod).`))
957      return
958    }
959  }
960  if (value && CATALOG.find(m => m.toggle === toggle)?.imported) {
961    const list = await trustedList($)
962    if (!list.includes(toggle)) await $.store.set(STORE_TRUSTED, [...list, toggle])
963  }
964  await set($, key, value)
965}
966
967/** Several switches at once: one write where the host lists no rows, else the reload chain. */
968async function setMany($: EngineInterface, values: Record<string, boolean>) {
969  if (SYNTHETIC || (await configRows($), SYNTHETIC)) return writeOptions($, values)
970  await $.store.set(STORE_PENDING, Object.entries(values))
971  await continueProfile($)
972}
973
974/**
975 * Mods that are new since the last session take the switch the active profile gives them, so a
976 * profile keeps meaning what it says when the box grows. Imported mods wait for trust.
977 */
978async function adoptNewMods($: EngineInterface) {
979  const all = CATALOG.map(m => m.toggle)
980  const seen = await $.store.get(STORE_SEEN).catch(() => null)
981  await $.store.set(STORE_SEEN, all)
982  if (!Array.isArray(seen)) return
983  const added = all.filter(x => !seen.includes(x))
984  if (!added.length) return
985  await update($, fresh, () => added)
986  const activeId = await activeProfileId($)
987  const p = PROFILES.find(x => x.id === activeId)
988  if (!p) return
989  const rows = await configRows($)
990  const want = profileSet(p, await trustedList($), rows).filter(([k, v]) => added.includes(k) && (rows.get(k)?.value === true) !== v)
991  if (!want.length) return
992  $.ui.toast(t(`Neu in der Mod Box: ${added.length}. Profil „${tx(p.label)}“ angewandt.`, `New in the Mod Box: ${added.length}. Profile "${tx(p.label)}" applied.`))
993  await setMany($, Object.fromEntries(want))
994}
995
996type Here = { os: string; surface: string; missing: string[] }
997
998// The operating system, found once per load: Windows from its OS variable, the rest from uname.
999// Unknown until then; an unknown system raises no OS warning rather than a wrong one.
1000let detectedOs: string | null = null
1001
1002/** windows | mac | linux from `uname -s` output, or null when it says something else. */
1003export function osFromUname(out: string): string | null {
1004  const name = out.trim().toLowerCase()
1005  if (name.startsWith('darwin')) return 'mac'
1006  if (name.startsWith('linux')) return 'linux'
1007  if (/^(mingw|msys|cygwin|windows)/.test(name)) return 'windows'
1008  return null
1009}
1010
1011async function detectOs($: EngineInterface) {
1012  if (detectedOs) return
1013  if ((await $.env.get('OS').catch(() => undefined)) === 'Windows_NT') {
1014    detectedOs = 'windows'
1015    return
1016  }
1017  const r = await $.process.run(['uname', '-s'], { timeoutMs: 5000 }).catch(() => null)
1018  detectedOs = r && r.exitCode === 0 ? osFromUname(r.stdout) : null
1019}
1020
1021async function here($: EngineInterface, surface: string): Promise<Here> {
1022  const isWin = detectedOs === 'windows' || (await $.env.get('OS').catch(() => undefined)) === 'Windows_NT'
1023  const os = isWin ? 'windows' : (detectedOs ?? '')
1024  const env = (await read($, envChecks)) ?? []
1025  const id: Record<string, string> = { node: 'node', git: 'git', gh: 'gh', sign: 'signScript', bash: 'bash' }
1026  const missing = env.filter(c => c.ok === false && id[c.id]).map(c => id[c.id]!)
1027  if (os && !isWin) missing.push('powershell')
1028  return { os, surface, missing }
1029}
1030
1031const SURFACE_NAME: Record<string, [string, string]> = { terminal: ['Terminal', 'terminal'], desktop: ['Desktop-App', 'desktop app'], vscode: ['VS Code', 'VS Code'], mobile: ['Mobil', 'mobile'] }
1032const OS_NAME: Record<string, string> = { windows: 'Windows', mac: 'macOS', linux: 'Linux' }
1033const NEED_NAME: Record<string, [string, string]> = { git: ['git', 'git'], gh: ['gh (angemeldet)', 'gh (signed in)'], node: ['Node.js', 'Node.js'], bash: ['Git-Bash', 'Git Bash'], powershell: ['PowerShell', 'PowerShell'], signScript: ['ein Signierskript', 'a signing script'] }
1034const name2 = (pair: [string, string] | undefined, fallback: string) => (pair ? t(pair[0], pair[1]) : fallback)
1035
1036export function compatText(c: CatalogMod['compat']): string {
1037  const surfaces = c.surfaces.length === 4 ? t('überall', 'everywhere') : c.surfaces.map(x => name2(SURFACE_NAME[x], x)).join(', ')
1038  const os = c.os.length === 3 ? '' : ' · ' + c.os.map(x => OS_NAME[x] ?? x).join(', ')
1039  const needs = c.needs.length ? ' · ' + t('braucht', 'needs') + ' ' + c.needs.map(x => name2(NEED_NAME[x], x)).join(', ') : ''
1040  return surfaces + os + needs
1041}
1042
1043/** Why a mod would not (fully) run here, each reason as German and English text. */
1044export function compatIssues(c: CatalogMod['compat'], h: Here): Array<{ de: string; en: string }> {
1045  const out: Array<{ de: string; en: string }> = []
1046  const list = (xs: string[], names: Record<string, [string, string]>, i: 0 | 1) => xs.map(x => names[x]?.[i] ?? x).join(', ')
1047  if (h.surface && !c.surfaces.includes(h.surface)) out.push({ de: 'nur ' + list(c.surfaces, SURFACE_NAME, 0), en: list(c.surfaces, SURFACE_NAME, 1) + ' only' })
1048  const osNames = c.os.map(x => OS_NAME[x] ?? x).join(', ')
1049  if (h.os && !c.os.includes(h.os)) out.push({ de: 'nur ' + osNames, en: osNames + ' only' })
1050  const lacking = c.needs.filter(x => h.missing.includes(x))
1051  if (lacking.length) out.push({ de: 'braucht ' + list(lacking, NEED_NAME, 0), en: 'needs ' + list(lacking, NEED_NAME, 1) })
1052  return out
1053}
1054
1055// ---------------------------------------------------------------- Native plugins
1056
1057// Plugins the box does not carry inside itself: installed as plugins of their own and driven
1058// through `claude plugin ...` (tools/native.mjs). Switching, options and updates take effect when
1059// Claude Code loads plugins again (a new session; in the desktop app a restart).
1060
1061async function loadNatives($: EngineInterface) {
1062  const res = await box($, ['native', 'list'])
1063  if (!res.ok) {
1064    await update($, natives, () => [])
1065    await update($, report, () => ({ ok: false, title: t('Installierte Plugins lesen', 'Reading installed plugins'), error: String(res.error ?? '?'), notes: [] }))
1066    return
1067  }
1068  const list: NativePlugin[] = ((res.plugins ?? []) as any[]).map(p => ({
1069    id: String(p.id),
1070    version: String(p.version ?? ''),
1071    enabled: p.enabled === true,
1072    managed: p.managed === true,
1073    trusted: p.trusted === true,
1074    group: String(p.group ?? 'native'),
1075    tokens: String(p.tokens ?? 'none'),
1076    hasUserConfig: p.hasUserConfig === true,
1077    errors: Array.isArray(p.errors) ? p.errors.map(String) : [],
1078    changedAt: p.changedAt ? String(p.changedAt) : null,
1079    missing: p.missing === true,
1080  }))
1081  await update($, natives, () => list)
1082}
1083
1084// Claude Code's own mods (@builtin): read once per window, switched through `claude plugin enable|disable`.
1085async function loadBuiltins($: EngineInterface, refresh = false) {
1086  const res = await box($, ['native', 'builtins', ...(refresh ? ['--refresh'] : [])], 120_000)
1087  await update($, builtinMods, () => (res.ok && Array.isArray(res.builtins) ? (res.builtins as BuiltinMod[]) : []))
1088}
1089
1090async function switchBuiltin($: EngineInterface, b: BuiltinMod) {
1091  if (!b.switchable) return
1092  await nativeRun($, [b.enabled ? 'builtin-off' : 'builtin-on', b.name], b.enabled ? t(`${b.name} wird ausgeschaltet …`, `Switching ${b.name} off …`) : t(`${b.name} wird eingeschaltet …`, `Switching ${b.name} on …`))
1093  await loadBuiltins($)
1094}
1095
1096/** Runs `box.mjs native <args>`; a failure becomes the report, the list is read again afterwards. */
1097async function nativeRun($: EngineInterface, args: string[], busyText: string, stdin?: string): Promise<any> {
1098  if (await read($, busy)) return null
1099  await update($, busy, () => busyText)
1100  let res: any = null
1101  try {
1102    res = await box($, ['native', ...args], 600_000, stdin)
1103    if (!res.ok) await update($, report, () => ({ ok: false, title: busyText.replace(/\s*…$/, ''), error: String(res.error ?? res.message ?? '?').slice(0, 1500), notes: [] }))
1104    else if (res.restart) $.ui.toast(t('Wirkt, sobald Claude Code die Plugins neu lädt (neue Sitzung; in der Desktop-App: neu starten).', 'Takes effect when Claude Code loads plugins again (a new session; in the desktop app: restart).'), { timeoutMs: 8000 })
1105  } finally {
1106    await update($, busy, () => null)
1107  }
1108  await loadNatives($)
1109  return res
1110}
1111
1112async function nativeInfoOf($: EngineInterface, id: string) {
1113  const [d, o] = await Promise.all([box($, ['native', 'details', id]), box($, ['native', 'options', id])])
1114  const info: NativeInfo = {
1115    components: d.ok ? (d.components as NativeInfo['components']) : null,
1116    alwaysOn: d.ok ? (d.tokens?.alwaysOn ?? null) : null,
1117    fields: o.ok ? ((o.fields ?? []) as NativeField[]) : null,
1118    ...(d.ok ? {} : { error: String(d.error ?? '') }),
1119  }
1120  await update($, nativeInfo, m => ({ ...m, [id]: info }))
1121}
1122
1123async function toggleNative($: EngineInterface, id: string) {
1124  const key = 'n:' + id
1125  const isOpening = !(await read($, expanded)).includes(key)
1126  toggleExpanded($, key)
1127  if (isOpening && !(await read($, nativeInfo))[id]) await nativeInfoOf($, id)
1128}
1129
1130async function switchNative($: EngineInterface, p: NativePlugin) {
1131  if (!p.enabled && p.managed && !p.trusted) {
1132    $.ui.toast(t('Erst vertrauen: das ist fremder Code (⚙ → Vertrauen).', 'Trust it first: it is foreign code (⚙ → Trust).'))
1133    return
1134  }
1135  await nativeRun($, [p.enabled ? 'disable' : 'enable', p.id], p.enabled ? t(`${p.id} wird ausgeschaltet …`, `Switching ${p.id} off …`) : t(`${p.id} wird eingeschaltet …`, `Switching ${p.id} on …`))
1136}
1137
1138async function trustNative($: EngineInterface, id: string, trusted: boolean) {
1139  const res = await nativeRun($, [trusted ? 'trust' : 'untrust', id], t('Vertrauen wird gesetzt …', 'Setting trust …'))
1140  // From now on the active profile decides about it, as about every other mod.
1141  if (res?.ok && trusted) await nativeRun($, ['profile', await activeProfileId($)], t('Profil wird angewandt …', 'Applying the profile …'))
1142}
1143
1144async function setNativeOption($: EngineInterface, id: string, key: string, value: string) {
1145  const res = await nativeRun($, ['set', id], t('Option wird gespeichert …', 'Saving the option …'), JSON.stringify({ [key]: value }))
1146  if (res?.ok) {
1147    $.ui.toast(t(`${key} gespeichert. Wirkt nach dem Neuladen der Plugins.`, `${key} saved. Takes effect when plugins load again.`))
1148    await nativeInfoOf($, id)
1149  }
1150}
1151
1152async function updateNative($: EngineInterface, id: string) {
1153  const res = await nativeRun($, ['update', id], t(`${id} wird aktualisiert …`, `Updating ${id} …`))
1154  if (!res?.ok) return
1155  const text = res.diffFile ? String(await $.fs.read(String(res.diffFile)).catch(() => '')) : ''
1156  await update($, report, () => ({
1157    ok: true,
1158    title: t(`${id} aktualisiert`, `${id} updated`),
1159    notes: [
1160      `${res.from || '?'} → ${res.to || '?'}`,
1161      t(`neu: ${res.added?.length ?? 0} · entfernt: ${res.removed?.length ?? 0} · geändert: ${res.changed?.length ?? 0}`, `added: ${res.added?.length ?? 0} · removed: ${res.removed?.length ?? 0} · changed: ${res.changed?.length ?? 0}`),
1162    ],
1163  }))
1164  if (text) {
1165    await update($, diff, () => ({ id, from: String(res.from ?? ''), to: String(res.to ?? ''), text }))
1166    await $.ui.open({ id: DIFF_PANE, title: `Diff ${id}` }).catch(() => {})
1167  }
1168}
1169
1170async function installNative($: EngineInterface, src: Source, p: SourcePlugin) {
1171  const args = src.kind === 'claude' && p.nativeId ? ['install', p.nativeId] : p.dir ? ['install', p.name, '--dir', p.dir] : ['install', p.name, '--source', src.link]
1172  const res = await nativeRun($, args, t(`${p.name} wird installiert …`, `Installing ${p.name} …`))
1173  if (res?.confirm) {
1174    await update($, report, () => ({ ok: false, title: t(`${p.name}: Befehl bestätigen`, `${p.name}: confirm a command`), error: t(`Der Marketplace will bei der Installation diesen Befehl ausführen: ${res.confirm.command}\nDie Box bestätigt das nicht selbst. Wenn du ihm traust: claude plugin install ${p.nativeId ?? p.name} --accept-command ${res.confirm.sha256}`, `The marketplace wants to run this command on install: ${res.confirm.command}\nThe box does not confirm it itself. If you trust it: claude plugin install ${p.nativeId ?? p.name} --accept-command ${res.confirm.sha256}`), notes: [] }))
1175    return
1176  }
1177  if (res?.ok) await update($, report, () => ({ ok: true, title: t(`${p.name} installiert`, `${p.name} installed`), notes: [t('Nativ installiert und ausgeschaltet, bis du ihm vertraust (Mods → Nativ verwaltet).', 'Installed natively and switched off until you trust it (Mods → Managed natively).')] }))
1178}
1179
1180const PART_LABEL: Record<string, [string, string]> = { Skills: ['Skills', 'skills'], Agents: ['Agents', 'agents'], Hooks: ['Hooks', 'hooks'], 'MCP servers': ['MCP-Server', 'MCP servers'], 'LSP servers': ['LSP-Server', 'LSP servers'] }
1181
1182function drawNativeCard($: EngineInterface, ui: any, list: NativePlugin[] | null, info: Record<string, NativeInfo>, open: string[], startedAt: number, builtins: BuiltinMod[] | null = null) {
1183  const { Box, Text, Button, Select, Input } = ui
1184  const managed = (list ?? []).filter(p => p.managed)
1185  const others = (list ?? []).filter(p => !p.managed)
1186  const on = managed.filter(p => p.enabled).length
1187  const row = (p: NativePlugin) => {
1188    const isOpen = open.includes('n:' + p.id)
1189    const [name, market] = p.id.split('@')
1190    const pending = !!p.changedAt && Date.parse(p.changedAt) > startedAt
1191    const inf = info[p.id]
1192    return (
1193      <Box key={`nat-${p.id}`} flexDirection="column" marginTop={isOpen ? 1 : 0} marginBottom={isOpen ? 1 : 0}>
1194        <Box flexDirection="row" alignItems="center" justifyContent="space-between">
1195          <Box flexDirection="row" alignItems="center" flexShrink={1}>
1196            <Button key={`nt-${p.id}`} plain onPress={() => void switchNative($, p)}>
1197              <Text color={p.enabled ? 'success' : 'inactive'}>{p.enabled ? '●' : '○'}</Text>
1198              {' '}
1199              <Text bold={p.enabled} color={p.enabled ? 'text' : 'inactive'}>{name}</Text>
1200            </Button>
mods/blast-radius/hooks/blast-radius.mjs 590 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified for mod-box (https://github.com/LupusMalusDeviant/mod-box): Windows support (PowerShell wait loop, Git Bash, Windows cd targets), state/matchers/paths rewritten for the single plugin, the held call offered to the Desktop Pet (blast:held / blast:pet in $.store).
4// Original: https://github.com/anthropics/claude-code-playground/tree/main/claude-code/mods/blast-radius (commit 569c528).
5//
6// Blast Radius: holds a risky Bash command and shows what it would change.
7//
8// tool.call (Bash): if the command is risky, work out its blast radius, open a
9// pane with Proceed and Cancel, and hold the call until one is pressed.
10// ui.render (Pane): draws the report. If the surface won't place the pane (a
11// narrow terminal), the same report is drawn in the AbovePrompt band instead.
12//
13// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
14// free. So the hold loop waits on a short `$.process.run(["sleep", ...])` until
15// a button's onPress sets the decision.
16//
17// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
18// spelled literally, and helpers that take `$` are top-level functions.
19
20const PANE_ID = "blast-radius";
21const POLL_SECONDS = "0.25";
22const HOLD_LIMIT_MS = 10 * 60 * 1000;
23const LIST_MAX = 10;
24
25// The call being held, or null. One at a time: Bash calls in a turn run in order.
26let held = null;
27
28export function register(on) {
29  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
30    const risk = classify(String(e.command ?? ""));
31    if (risk === null) {
32      return next(e);
33    }
34    // One hold at a time. If another risky call is already held (a subagent's,
35    // say), wait until it is answered. `held` is claimed with no await between
36    // the check and the claim, so two waiting calls can't both get through.
37    while (held !== null) {
38      if (next.signal.aborted) {
39        return { deny: "Blast Radius held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to." };
40      }
41      await pause($);
42    }
43    const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" };
44    held = mine;
45
46    let opened = { isPlaced: false };
47    let decision;
48    let summary = risk.label;
49    try {
50      // Measure where the command will run: the session folder, moved by any
51      // `cd dir &&` or `git -C dir` earlier in the same command line.
52      const sessionCwd = await $.session.cwd();
53      const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
54      mine.report = cwd === null
55        ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
56        : await measure($, risk, cwd);
57      summary = mine.report.summary;
58
59      opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
60      if (!opened.isPlaced) {
61        mine.where = "band";
62      }
63      $.ui.invalidate("ui.render");
64      // Mod Box: the Desktop Pet shows the held call (and, when it may answer, its buttons).
65      petKey = `blast:held:${await $.session.id().catch(() => "")}`;
66      mine.id = String(await $.clock.now());
67      await $.store.set(petKey, { id: mine.id, label: risk.label, summary, command: mine.command }).catch(() => {});
68
69      const startedAt = await $.clock.now();
70      while (mine.decision === null) {
71        await fromPet($, mine);
72        if (mine.decision !== null) {
73          break;
74        }
75        if (next.signal.aborted) {
76          mine.decision = "interrupted";
77          break;
78        }
79        if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
80          mine.decision = "timeout";
81          break;
82        }
83        await pause($);
84      }
85    } catch {
86      mine.decision = "error"; // anything unexpected refuses the command
87    } finally {
88      decision = mine.decision;
89      // Close this call's pane before releasing the hold, so the next call's
90      // pane can't be the one that gets closed.
91      try {
92        if (opened.isPlaced) {
93          await $.ui.close({ id: PANE_ID });
94        }
95      } catch {
96        // the pane is already gone
97      }
98      if (held === mine) {
99        held = null;
100      }
101      await $.store.delete(petKey).catch(() => {});
102      $.ui.invalidate("ui.render");
103    }
104
105    if (decision === "proceed") {
106      $.ui.toast("Blast Radius: running it");
107      return next(e);
108    }
109    const why = {
110      cancel: "the user pressed Cancel",
111      timeout: "no answer within 10 minutes",
112      interrupted: "the turn was interrupted",
113      error: "Blast Radius hit an error while holding it",
114    }[decision] ?? "no answer was recorded";
115    return {
116      deny: `Blast Radius held this command and did not run it: ${why}. It would have: ${summary}. Do not retry it unless the user asks you to.`,
117    };
118  });
119
120  on("ui.render", { component: "Pane" }, ($, e, next) => {
121    if (e.requestId !== PANE_ID || held === null || held.report === null) {
122      return next(e);
123    }
124    return draw($.ui.resolve(e), held);
125  });
126
127  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
128    if (held === null || held.report === null || held.where !== "band") {
129      return next(e);
130    }
131    return draw($.ui.resolve(e), held);
132  });
133}
134
135// ---- What counts as risky -------------------------------------------------
136
137// sudo options that take a value, so the value isn't read as the command.
138const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
139// Commands that only read, so a bare word "migrate" in them isn't a migration.
140const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
141
142/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
143function joinDir(dir, arg) {
144  if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
145    return arg ?? "~";
146  }
147  return dir ? `${dir}/${arg}` : arg;
148}
149
150/** The first risky segment of a shell command, or null. */
151function classify(command) {
152  let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
153  const scopes = []; // dir to restore when a ( subshell ) closes
154  const pushed = []; // pushd stack, for popd
155  for (const raw of command.split(/&&|\|\||;|\||\n/)) {
156    const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
157    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
158    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
159    const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
160    for (let k = 0; k < opens; k += 1) {
161      scopes.push(dir);
162    }
163    const risk = classifySegment(raw, dir, pushed);
164    if (risk !== null && risk.cd === undefined) {
165      return risk;
166    }
167    if (risk !== null) {
168      dir = risk.cd; // a cd, pushd or popd moved the folder
169    }
170    for (let k = 0; k < closes && scopes.length > 0; k += 1) {
171      dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
172    }
173  }
174  return null;
175}
176
177// Words that can come before the real command without changing what it does.
178const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
179
180/** One segment: a risk, { cd } for a folder change, or null. */
181function classifySegment(segment, dir, pushed) {
182  {
183    const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
184    while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
185      words.shift(); // leading VAR=value
186    }
187    if (words[0] === "sudo") {
188      words.shift();
189      while (words.length > 0 && words[0].startsWith("-")) {
190        const option = words.shift();
191        if (SUDO_VALUE_OPTIONS.has(option)) {
192          words.shift();
193        }
194      }
195    }
196    while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
197      words.shift();
198    }
199    if (words[0] === "nice") {
200      words.shift();
201      if (words[0] === "-n") {
202        words.splice(0, 2);
203      } else if (/^-\d+$/.test(words[0] ?? "")) {
204        words.shift();
205      }
206    }
207    const [first, ...args] = words;
208    if (first === undefined) {
209      return null;
210    }
211    const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
212    if (cmd === "cd") {
213      return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
214    }
215    if (cmd === "pushd") {
216      pushed.push(dir);
217      return { cd: joinDir(dir, args[0]) };
218    }
219    if (cmd === "popd") {
220      return { cd: pushed.length > 0 ? pushed.pop() : "-" };
221    }
222    if (cmd === "rm" || cmd.endsWith("/rm")) {
223      const flags = args.filter((a) => a.startsWith("-"));
224      const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
225      const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
226      if (recursive || force) {
227        const targets = args.filter((a) => !a.startsWith("-") || a === "-");
228        return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
229      }
230    }
231    if (cmd === "git") {
232      // Git's own options come before the subcommand; -C moves where it runs.
233      let gitDir = dir;
234      let i = 0;
235      while (i < args.length && args[i].startsWith("-")) {
236        if (args[i] === "-C" && i + 1 < args.length) {
237          gitDir = joinDir(gitDir, args[i + 1]);
238          i += 2;
239        } else if (args[i] === "-c" && i + 1 < args.length) {
240          i += 2;
241        } else {
242          i += 1;
243        }
244      }
245      const sub = args[i];
246      const rest = args.slice(i + 1);
247      if (sub === "reset" && rest.includes("--hard")) {
248        return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
249      }
250      if (sub === "clean") {
251        return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
252      }
253      if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
254        return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
255      }
256      const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
257      if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
258        return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
259      }
260    }
261    const joined = words.join(" ");
262    if (/\balembic\s+upgrade\b/.test(joined)) {
263      return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
264    }
265    if (/\bdb:migrate(?!:status\b)/.test(joined)) {
266      return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
267    }
268    if (/\bprisma\s+migrate\b/.test(joined)) {
269      return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
270    }
271    if (/\bmanage\.py\s+migrate\b/.test(joined)) {
272      return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
273    }
274    if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
275      return { kind: "migrate", tool: "unknown", label: "migrate", dir };
276    }
277  }
278  return null;
279}
280
281// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
282// The target is passed as an argument, never as source.
283const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && { pwd -W 2>/dev/null || pwd -P; }`;
284// Mod Box: under Git Bash (Windows) `pwd -W` prints the Windows path (X:/…) that $.process.run
285// accepts as working folder; elsewhere it fails and `pwd -P` answers as in the original.
286
287async function resolveDir($, sessionCwd, dir) {
288  if (dir === "-") {
289    return null; // `cd -` depends on the shell's history
290  }
291  const run = await $.process.run([await bashExe($), "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
292  const out = run.stdout.trim();
293  return run.exitCode === 0 && out !== "" ? out : null;
294}
295
296/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
297function tokenize(text) {
298  const words = [];
299  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
300  let m;
301  while ((m = re.exec(text)) !== null) {
302    words.push(m[1] ?? m[2] ?? m[3]);
303  }
304  return words;
305}
306
307// ---- Measuring the blast radius -------------------------------------------
308
309/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
310async function measure($, risk, cwd) {
311  try {
312    if (risk.kind === "rm") {
313      return await measureRm($, risk, cwd);
314    }
315    if (risk.kind === "migrate") {
316      return await measureMigrations($, risk, cwd);
317    }
318    return await measureGit($, risk, cwd);
319  } catch (error) {
320    return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
321  }
322}
323
324// The paths are passed to bash as arguments, never as source, so nothing in
325// them runs. compgen -G expands a glob without command substitution.
326const RM_SCRIPT = `
327shopt -s nullglob dotglob
328paths=()
329for p in "$@"; do
330  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
331  if [[ "$p" == *[*?[]* ]]; then
332    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
333  elif [[ -e "$p" || -L "$p" ]]; then
334    paths+=("$p")
335  fi
336done
337if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
338# A relative path gets ./ in front, so find never reads a name like -delete as an action.
339for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
340files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
341kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
342echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
343find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
344`;
345
346async function measureRm($, risk, cwd) {
347  if (risk.targets.length === 0) {
348    return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
349  }
350  const run = await $.process.run([await bashExe($), "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
351  const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
352  const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
353  if (!found) {
354    return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
355  }
356  if (!files) {
357    return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
358  }
359  return {
360    summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
361    lines: rest.map((l) => l.replace(/^\.\//, "")),
362    more: Math.max(0, files - rest.length),
363    note: `Paths: ${risk.targets.join(" ")}`,
364  };
365}
366
367async function measureGit($, risk, cwd) {
368  if (risk.kind === "git-push-force") {
369    return await measurePush($, risk, cwd);
370  }
371  if (risk.kind === "git-clean") {
372    const flags = [];
373    const paths = [];
374    for (let i = 0; i < risk.args.length; i += 1) {
375      const a = risk.args[i];
376      if (a === "--") {
377        paths.push(...risk.args.slice(i + 1));
378        break;
379      }
380      if (a === "-e" || a === "--exclude") {
381        flags.push(a, risk.args[i + 1] ?? "");
382        i += 1;
383      } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
384        flags.push(a);
385      } else if (/^-[a-zA-Z]+$/.test(a)) {
386        const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
387        if (kept !== "-") {
388          flags.push(kept);
389        }
390      } else if (!a.startsWith("-")) {
391        paths.push(a);
392      }
393    }
394    const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
395    if (run.exitCode !== 0) {
396      return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
397    }
398    const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
399    return {
400      summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
401      lines: gone.slice(0, LIST_MAX),
402      more: Math.max(0, gone.length - LIST_MAX),
403      note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
404    };
405  }
406  const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
407  if (status.exitCode !== 0) {
408    return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
409  }
410  const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
411  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
412  const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
413  const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
414  return {
415    summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
416    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
417    more: Math.max(0, lost.length - LIST_MAX),
418    note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
419  };
420}
421
422async function measurePush($, risk, cwd) {
423  const positional = risk.args.filter((a) => !a.startsWith("-"));
424  const remote = positional[0] ?? "origin";
425  // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
426  const spec = (positional[1] ?? "").replace(/^\+/, "");
427  let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
428  branch = (branch ?? "").replace(/^refs\/heads\//, "");
429  if (!branch) {
430    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
431    branch = head.stdout.trim();
432    source = "HEAD";
433  } else if (branch === "HEAD") {
434    // `git push origin HEAD` pushes the current branch to its namesake.
435    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
436    branch = head.stdout.trim();
437    source = "HEAD";
438  }
439  source = source || "HEAD";
440  const ref = `${remote}/${branch}`;
441  const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
442  if (known.exitCode !== 0) {
443    return { summary: `force-push to ${ref}`, lines: [], note: `No local copy of ${ref}, so I can't tell which commits the push would drop. Run git fetch first.` };
444  }
445  const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
446  const dropped = log.stdout.split("\n").filter((l) => l !== "");
447  return {
448    summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
449    lines: dropped.slice(0, LIST_MAX),
450    more: Math.max(0, dropped.length - LIST_MAX),
451    note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
452  };
453}
454
455const MIGRATION_LISTERS = {
456  django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
457  alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
458  rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
459  prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
460};
461
462async function measureMigrations($, risk, cwd) {
463  const lister = MIGRATION_LISTERS[risk.tool];
464  if (lister === undefined) {
465    return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
466  }
467  let run;
468  try {
469    run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
470  } catch (error) {
471    run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
472  }
473  if (run.exitCode !== 0) {
474    return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
475  }
476  const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
477  return {
478    summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
479    lines: pending.slice(0, LIST_MAX),
480    more: Math.max(0, pending.length - LIST_MAX),
481    note: `From ${lister.argv.join(" ")}.`,
482  };
483}
484
485function size(bytes) {
486  if (!Number.isFinite(bytes) || bytes < 1024) {
487    return `${bytes || 0} B`;
488  }
489  const units = ["KB", "MB", "GB", "TB"];
490  let n = bytes;
491  let i = -1;
492  while (n >= 1024 && i < units.length - 1) {
493    n /= 1024;
494    i += 1;
495  }
496  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
497}
498
499// ---- Drawing --------------------------------------------------------------
500
501function paneRows(report) {
502  return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
503}
504
505// Mod Box: where the held call waits for the Desktop Pet; the pet's answer lands beside it.
506let petKey = "blast:held:";
507async function fromPet($, mine) {
508  const key = petKey.replace("blast:held:", "blast:pet:");
509  const a = await $.store.get(key).catch(() => null);
510  if (!a || typeof a !== "object") {
511    return;
512  }
513  await $.store.delete(key).catch(() => {});
514  if (a.id === mine.id && (a.choice === "proceed" || a.choice === "cancel") && mine.decision === null) {
515    mine.decision = a.choice;
516  }
517}
518
519function draw(t, state) {
520  const { Box, Text, Button } = t;
521  const { report } = state;
522  const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: `  ${line}`, wrap: "truncate-end" }));
523  if (report.more) {
524    list.push(Text({ key: "more", dimColor: true, children: `  + ${report.more} more` }));
525  }
526  // The buttons answer the call this pane was drawn for, never whichever one is held now.
527  const decide = (choice) => () => {
528    if (state.decision === null) {
529      state.decision = choice;
530    }
531  };
532  return Box({
533    flexDirection: "column",
534    borderStyle: "round",
535    borderColor: "yellow",
536    paddingX: 1,
537    children: [
538      Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
539      Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command  " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
540      Text({ key: "sum", children: [Text({ dimColor: true, children: "Would    " }), Text({ color: "red", bold: true, children: report.summary })] }),
541      Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
542      report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
543      Box({
544        key: "buttons",
545        marginTop: 1,
546        gap: 2,
547        children: [
548          Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
549          Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
550          Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
551        ],
552      }),
553    ],
554  });
555}
556
557// Mod Box: wait in a process so it doesn't count against the hook's time budget (as in the
558// original). Windows has no "sleep" on PATH; PowerShell does it there.
559let sleepArgv = null;
560async function pause($) {
561  if (sleepArgv === null) {
562    sleepArgv = (await $.env.get("OS")) === "Windows_NT"
563      ? ["powershell", "-NoProfile", "-NonInteractive", "-Command", `Start-Sleep -Milliseconds ${Math.round(Number(POLL_SECONDS) * 1000)}`]
564      : ["sleep", POLL_SECONDS];
565  }
566  await $.process.run(sleepArgv, { timeoutMs: 10000 });
567}
568
569// Mod Box: on Windows, "bash" on PATH is often the WSL launcher, which doesn't know Windows paths.
570// Git Bash ships the GNU tools the scripts above need (find, head, du).
571let bashPath = null;
572async function bashExe($) {
573  if (bashPath === null) {
574    bashPath = "bash";
575    if ((await $.env.get("OS")) === "Windows_NT") {
576      const candidates = [
577        `${(await $.env.get("ProgramFiles")) ?? "C:/Program Files"}/Git/bin/bash.exe`,
578        `${(await $.env.get("LOCALAPPDATA")) ?? ""}/Programs/Git/bin/bash.exe`,
579      ];
580      for (const c of candidates) {
581        if (await $.fs.exists(c).catch(() => false)) {
582          bashPath = c;
583          break;
584        }
585      }
586    }
587  }
588  return bashPath;
589}
590
mods/secret-guard/hooks/register.tsx 411 lines
1// Secret Guard: the counterpart of Blast Radius for secrets. Stops before Claude
2//  - writes an API key, token or private key into a file (Write, Edit, MultiEdit, NotebookEdit),
3//  - writes or `git add`s a .env, .pem, id_rsa & co. that git does not ignore,
4//  - commits while a secret sits in the staged changes,
5//  - redirects a secret from the command line into a file.
6// Mode "block" refuses the action (Claude gets the reason), "warn" only reports.
7//
8// Exceptions, so test keys and example files pass:
9//  - project: .secretguard-allow in the project root (can be committed, applies to the team),
10//  - global: in the box's store (all projects).
11// An entry is a path in gitignore style (tests/fixtures/**, *.example.ts) or a value as
12// sha256:<hex>, so no secret ever stands in clear text in an exception list. Placeholders
13// (EXAMPLE, DUMMY, FAKE, xxxx …) pass on their own (option); that covers the documentation keys
14// of AWS too, which all contain "EXAMPLE".
15// Only the person changes the exceptions (window /secret-allow); Claude writing the list is stopped.
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, Register } from 'claude-code'
18
19import type { BlockedFind } from '../types'
20import { PATTERNS, isPlaceholder } from './patterns'
21
22type Hit = { what: string; where: string; path: string | null; value: string | null; hard?: boolean }
23
24const blocked = atom({ plugin: 'mod-box', key: 'sw_blocked' } as const, [] as BlockedFind[])
25const projectLines = atom({ plugin: 'mod-box', key: 'sw_project' } as const, [] as string[])
26const globalLines = atom({ plugin: 'mod-box', key: 'sw_global' } as const, [] as string[])
27
28export const ALLOW_FILE = '.secretguard-allow'
29const STORE_GLOBAL = 'secret-allow-global'
30const PANE = 'mod-box-secret-allow'
31const MAX_BLOCKED = 20
32
33// Display language and options (module level: the pane and the checks read them).
34let lang: 'de' | 'en' = 'en'
35const t = (de: string, en: string) => (lang === 'en' ? en : de)
36let placeholders = true
37let isWindows = false
38
39// still exported here for the tests and older imports
40export { PATTERNS, isPlaceholder }
41
42// Files that hold secrets; templates (.example, .sample, .template, .dist) are fine.
43export const SECRET_FILE = /(^|[\\/])(\.env(\.[\w-]+)?|[^\\/]*\.(pem|key|p12|pfx|keystore|jks)|id_(rsa|dsa|ecdsa|ed25519)|credentials(\.json)?|\.npmrc|\.pypirc|\.netrc)$/i
44const TEMPLATE = /\.(example|sample|template|dist)$/i
45
46export function findSecrets(text: string, where: string, path: string | null = null): Hit[] {
47  const hits: Hit[] = []
48  for (const [what, re] of PATTERNS) {
49    for (const m of text.matchAll(re)) hits.push({ what, where, path, value: m[0] })
50  }
51  return hits
52}
53
54export function isSecretFile(file: string): boolean {
55  return SECRET_FILE.test(file) && !TEMPLATE.test(file)
56}
57
58// Paths of a `git add …`; "." and "-A" count as "everything".
59export function gitAddPaths(command: string): string[] | 'all' | null {
60  const m = /(?:^|[;&|]\s*)git\s+(?:-C\s+\S+\s+)?add\s+([^;&|]*)/.exec(command)
61  if (!m) return null
62  const words = (m[1] ?? '').trim().split(/\s+/).filter(Boolean)
63  if (words.some(w => w === '.' || w === '-A' || w === '--all' || w === '*')) return 'all'
64  return words.filter(w => !w.startsWith('-'))
65}
66
67// ---------------------------------------------------------------- exceptions
68
69/** An exception line: a path pattern, or sha256:<hex>. Comments after # are dropped. */
70export function parseAllow(lines: string[]): { globs: string[]; hashes: Set<string> } {
71  const globs: string[] = []
72  const hashes = new Set<string>()
73  for (const raw of lines) {
74    const line = raw.replace(/\s+#.*$/, '').replace(/^#.*$/, '').trim()
75    if (!line) continue
76    const h = /^sha256:([0-9a-f]{64})$/i.exec(line)
77    if (h) hashes.add(h[1]!.toLowerCase())
78    else globs.push(line)
79  }
80  return { globs, hashes }
81}
82
83/** gitignore-like matching: no slash = any folder level; a slash = from the project root; ** spans folders. */
84export function globMatch(glob: string, path: string, ignoreCase = false): boolean {
85  let g = glob.trim().replace(/\\/g, '/')
86  if (!g) return false
87  const dirOnly = g.endsWith('/')
88  if (dirOnly) g = g.slice(0, -1)
89  const anchored = g.startsWith('/') || g.includes('/')
90  g = g.replace(/^\//, '')
91  let re = ''
92  for (let i = 0; i < g.length; i++) {
93    const c = g[i]!
94    if (c === '*' && g[i + 1] === '*') {
95      re += '.*'
96      i++
97      if (g[i + 1] === '/') i++
98    } else if (c === '*') re += '[^/]*'
99    else if (c === '?') re += '[^/]'
100    else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
101  }
102  const body = anchored ? `^${re}` : `(^|.*/)${re}`
103  return new RegExp(`${body}(/.*)?$`, ignoreCase ? 'i' : '').test(path.replace(/\\/g, '/').replace(/^\.\//, ''))
104}
105
106export async function sha256(text: string): Promise<string> {
107  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
108  return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join('')
109}
110
111async function projectRoot($: EngineInterface): Promise<string> {
112  return (await $.session.cwd()).replace(/\\/g, '/').replace(/\/$/, '')
113}
114
115function relative(root: string, file: string): string {
116  const f = file.replace(/\\/g, '/')
117  const r = root + '/'
118  if (isWindows ? f.toLowerCase().startsWith(r.toLowerCase()) : f.startsWith(r)) return f.slice(r.length)
119  return f.replace(/^\.\//, '')
120}
121
122let projectCache: { path: string; mtime: number; lines: string[] } | null = null
123
124async function loadProjectLines($: EngineInterface): Promise<string[]> {
125  const file = `${await projectRoot($)}/${ALLOW_FILE}`
126  const st = await $.fs.stat(file).catch(() => null)
127  if (!st) {
128    projectCache = { path: file, mtime: 0, lines: [] }
129    return []
130  }
131  if (projectCache && projectCache.path === file && projectCache.mtime === st.mtimeMs) return projectCache.lines
132  const lines = String(await $.fs.read(file).catch(() => '')).split(/\r?\n/)
133  projectCache = { path: file, mtime: st.mtimeMs, lines }
134  return lines
135}
136
137async function loadGlobalLines($: EngineInterface): Promise<string[]> {
138  const saved = await $.store.get(STORE_GLOBAL).catch(() => null)
139  return Array.isArray(saved) ? saved.filter((x): x is string => typeof x === 'string') : []
140}
141
142/** Drops the hits an exception covers. */
143async function applyExceptions($: EngineInterface, hits: Hit[]): Promise<Hit[]> {
144  if (hits.length === 0) return hits
145  const all = parseAllow([...(await loadProjectLines($)), ...(await loadGlobalLines($))])
146  const out: Hit[] = []
147  for (const h of hits) {
148    if (h.hard) {
149      out.push(h)
150      continue
151    }
152    if (h.path && all.globs.some(g => globMatch(g, h.path!, isWindows))) continue
153    if (h.value) {
154      if (placeholders && isPlaceholder(h.value)) continue
155      if (all.hashes.has(await sha256(h.value))) continue
156    }
157    out.push(h)
158  }
159  return out
160}
161
162// ---------------------------------------------------------------- the guard
163
164export const register: Register = (on, options) => {
165  const mode = options.mode === 'warn' ? 'warn' : 'block'
166  lang = options.language === 'de' ? 'de' : 'en'
167  placeholders = options.placeholders !== 'off'
168
169  on('session.start', {}, async ($, e, next) => {
170    const r = await next(e)
171    isWindows = (await $.env.get('OS')) === 'Windows_NT'
172    await $.command.register({ name: 'secret-allow', description: t('Secret-Wächter: Ausnahmen verwalten', 'Secret Guard: manage exceptions') })
173    return r
174  })
175
176  on('command.run', { command: 'secret-allow' }, async $ => {
177    await refreshLists($)
178    await $.ui.open({ id: PANE, title: t('Secret-Wächter · Ausnahmen', 'Secret Guard · exceptions'), focus: true, closeOnEscape: true })
179    return { text: t('Ausnahmen des Secret-Wächters geöffnet.', 'Secret Guard exceptions opened.') }
180  })
181
182  on('tool.call', {}, async ($, e, next) => {
183    const found = await check($, e as unknown as Record<string, unknown>)
184    const hits = dedupe(await applyExceptions($, found))
185    if (hits.length === 0) return next(e)
186    const list = hits.map(h => `${h.what} (${h.where})`).join('; ')
187    const isHard = hits.some(h => h.hard)
188    await remember($, hits, mode === 'block' || isHard)
189    if (mode === 'warn' && !isHard) {
190      $.ui.toast(`${t('Secret-Wächter', 'Secret Guard')}: ${list}`)
191      return next(e)
192    }
193    $.ui.toast(`${t('Secret-Wächter hat angehalten', 'Secret Guard stopped')}: ${list} · /secret-allow`)
194    return {
195      deny: isHard
196        ? `Secret Guard stopped this: ${list}. The exception list ${ALLOW_FILE} is changed only by the user, through the Mod Box window (/secret-allow). Do not try to change it another way.`
197        : `Secret Guard stopped this action: ${list}. Secrets do not belong in files in the repository or in commits. ` +
198          'Use an environment variable or a file that is not versioned (in .gitignore), and write a placeholder instead. ' +
199          `If this is a known test key or an example file, the user can allow it with /secret-allow; do not edit ${ALLOW_FILE} yourself. ` +
200          'Do not retry it another way unless the user explicitly asks.',
201    }
202  })
203  // No .catch: if the check itself fails (no git, say), the action goes on rather than blocking every edit.
204
205  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($, e))
206}
207
208async function check($: EngineInterface, e: Record<string, unknown>): Promise<Hit[]> {
209  const tool = String(e.tool)
210  const root = await projectRoot($)
211  const file = typeof e.file_path === 'string' ? e.file_path : typeof e.notebook_path === 'string' ? e.notebook_path : ''
212  if (tool === 'Write' || tool === 'Edit' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
213    const rel = relative(root, file)
214    const where = file.split(/[\\/]/).pop() || file
215    // The exception list belongs to the person.
216    if (where === ALLOW_FILE) return [{ what: t('Änderung der Ausnahmeliste', 'change of the exception list'), where, path: rel, value: null, hard: true }]
217    const texts: string[] = []
218    if (typeof e.content === 'string') texts.push(e.content)
219    if (typeof e.new_string === 'string') texts.push(e.new_string)
220    if (typeof e.new_source === 'string') texts.push(e.new_source)
221    if (Array.isArray(e.edits)) for (const x of e.edits) if (x && typeof x.new_string === 'string') texts.push(x.new_string)
222    const hits = texts.flatMap(text => findSecrets(text, where, rel))
223    // A .env may hold secrets when git ignores it.
224    if (isSecretFile(file)) {
225      if (await isIgnored($, file)) return []
226      hits.push({ what: t('Geheimnis-Datei, die git nicht ignoriert', 'secret file that git does not ignore'), where, path: rel, value: null })
227    }
228    return hits
229  }
230  if (tool === 'Bash' && typeof e.command === 'string') {
231    const cmd = e.command
232    const hits: Hit[] = []
233    if (cmd.includes(ALLOW_FILE) && /(>|\btee\b|\bsed\s+-i|\brm\b|\bmv\b|\bcp\b|Set-Content|Add-Content|Out-File|Remove-Item|\btruncate\b)/.test(cmd)) {
234      hits.push({ what: t('Änderung der Ausnahmeliste', 'change of the exception list'), where: ALLOW_FILE, path: ALLOW_FILE, value: null, hard: true })
235    }
236    const add = gitAddPaths(cmd)
237    if (add && add !== 'all') for (const p of add) if (isSecretFile(p)) hits.push({ what: t('git add einer Geheimnis-Datei', 'git add of a secret file'), where: p, path: relative(root, p), value: null })
238    if (add === 'all') {
239      // What "git add ." would take: changed and new files that are not ignored.
240      const r = await $.process.run(['git', 'status', '--porcelain', '--untracked-files=all']).catch(() => null)
241      for (const line of r?.exitCode === 0 ? r.stdout.split('\n') : []) {
242        const p = line.slice(3).trim().replace(/^"|"$/g, '')
243        if (p && isSecretFile(p)) hits.push({ what: t('git add . würde eine Geheimnis-Datei aufnehmen', 'git add . would stage a secret file'), where: p, path: p, value: null })
244      }
245    }
246    if (/(?:^|[;&|]\s*)git\s+(?:-C\s+\S+\s+)?commit\b/.test(cmd)) {
247      const staged = await $.process.run(['git', 'diff', '--cached', '--name-only']).catch(() => null)
248      for (const p of staged?.exitCode === 0 ? staged.stdout.split('\n').filter(Boolean) : []) {
249        if (isSecretFile(p)) hits.push({ what: t('Commit mit einer Geheimnis-Datei', 'commit with a secret file'), where: p, path: p, value: null })
250      }
251      const diff = await $.process.run(['git', 'diff', '--cached', '-U0', '--no-color']).catch(() => null)
252      if (diff?.exitCode === 0) hits.push(...secretsInDiff(diff.stdout))
253    }
254    // A secret on the command line that is redirected into a file (echo KEY > file).
255    const target = /(?:>>?|\btee\s+(?:-a\s+)?)\s*(["']?)([^\s"';&|]+)\1/.exec(cmd)
256    if (target) hits.push(...findSecrets(cmd, t('Umleitung in eine Datei', 'redirect into a file'), relative(root, target[2]!)))
257    return hits
258  }
259  return []
260}
261
262/** Added lines of a staged diff, per file, so path exceptions apply. */
263export function secretsInDiff(diff: string): Hit[] {
264  const hits: Hit[] = []
265  let path: string | null = null
266  let added: string[] = []
267  const flush = () => {
268    if (path && added.length) hits.push(...findSecrets(added.join('\n'), path, path))
269    added = []
270  }
271  for (const line of diff.split('\n')) {
272    if (line.startsWith('+++ ')) {
273      flush()
274      path = line.slice(4).replace(/^b\//, '').trim()
275      if (path === '/dev/null') path = null
276    } else if (line.startsWith('+') && !line.startsWith('+++')) added.push(line.slice(1))
277  }
278  flush()
279  return hits
280}
281
282async function isIgnored($: EngineInterface, file: string): Promise<boolean> {
283  const r = await $.process.run(['git', 'check-ignore', '-q', file]).catch(() => null)
284  return r?.exitCode === 0
285}
286
287function dedupe(hits: Hit[]): Hit[] {
288  const seen = new Set<string>()
289  return hits.filter(h => {
290    const k = `${h.what}|${h.where}|${h.value ?? ''}`
291    if (seen.has(k)) return false
292    seen.add(k)
293    return true
294  })
295}
296
297// Keeps the last finds for the window (hash only, never the value).
298async function remember($: EngineInterface, hits: Hit[], stopped: boolean) {
299  const now = await $.clock.now()
300  const finds: BlockedFind[] = []
301  for (const h of hits) {
302    if (h.hard) continue
303    finds.push({ at: now, what: h.what, where: h.where, path: h.path, hash: h.value ? await sha256(h.value) : null, stopped })
304  }
305  if (finds.length) await update($, blocked, list => [...finds, ...list].slice(0, MAX_BLOCKED)).catch(() => {})
306}
307
308// ---------------------------------------------------------------- the exceptions window
309
310async function refreshLists($: EngineInterface) {
311  projectCache = null
312  const lines = await loadProjectLines($)
313  await update($, projectLines, () => lines.filter(l => l.trim() !== ''))
314  const g = await loadGlobalLines($)
315  await update($, globalLines, () => g)
316}
317
318async function addEntry($: EngineInterface, scope: 'project' | 'global', entry: string) {
319  const line = entry.trim()
320  if (!line) return
321  if (scope === 'global') {
322    const list = await loadGlobalLines($)
323    if (!list.includes(line)) await $.store.set(STORE_GLOBAL, [...list, line])
324  } else {
325    const file = `${await projectRoot($)}/${ALLOW_FILE}`
326    const existing = await $.fs.read(file).then(String).catch(() => '')
327    const header = existing ? '' : `# ${t('Ausnahmen für den Secret-Wächter der Mod Box: Pfade (gitignore-Stil) oder sha256:<hash> eines Werts.', 'Exceptions for the Mod Box Secret Guard: paths (gitignore style) or sha256:<hash> of a value.')}\n`
328    const lines = existing.split(/\r?\n/)
329    if (!lines.some(l => l.replace(/\s+#.*$/, '').trim() === line.replace(/\s+#.*$/, '').trim())) {
330      await $.fs.write(file, (existing ? existing.replace(/\s*$/, '\n') : header) + line + '\n')
331    }
332  }
333  $.ui.toast(t(`Ausnahme hinzugefügt (${scope === 'global' ? 'global' : ALLOW_FILE}).`, `Exception added (${scope === 'global' ? 'global' : ALLOW_FILE}).`))
334  await refreshLists($)
335}
336
337async function removeEntry($: EngineInterface, scope: 'project' | 'global', entry: string) {
338  if (scope === 'global') {
339    await $.store.set(STORE_GLOBAL, (await loadGlobalLines($)).filter(l => l !== entry))
340  } else {
341    const file = `${await projectRoot($)}/${ALLOW_FILE}`
342    const existing = await $.fs.read(file).then(String).catch(() => '')
343    await $.fs.write(file, existing.split(/\r?\n/).filter(l => l !== entry).join('\n'))
344  }
345  await refreshLists($)
346}
347
348async function drawPane($: EngineInterface, e: any) {
349  const { Box, Text, Button, Input } = $.ui.resolve(e) as any
350  const finds = await read($, blocked)
351  const proj = await read($, projectLines)
352  const glob = await read($, globalLines)
353  const when = (ms: number) => new Date(ms).toLocaleTimeString(lang === 'en' ? 'en-GB' : 'de-DE', { hour: '2-digit', minute: '2-digit' })
354  const entryRow = (scope: 'project' | 'global', line: string, i: number) =>
355    line.trim().startsWith('#') ? (
356      <Text key={`${scope}-c-${i}`} dimColor>{`  ${line}`}</Text>
357    ) : (
358      <Box key={`${scope}-${i}`} flexDirection="row">
359        <Text>{`  ${line.length > 90 ? line.slice(0, 89) + '…' : line}`}</Text>
360        <Text>{'  '}</Text>
361        <Button key={`sa-rm-${scope}-${i}`} label={t('Entfernen', 'Remove')} plain onPress={() => void removeEntry($, scope, line)} />
362      </Box>
363    )
364  return (
365    <Box flexDirection="column" paddingX={1}>
366      <Text dimColor wrap="wrap">
367        {t(
368          `Ausnahmen lassen bekannte Test-Schlüssel und Beispieldateien durch. Einträge: ein Pfad im gitignore-Stil (tests/fixtures/**, *.example.ts) oder sha256:<hash> eines Werts – Schlüssel stehen nie im Klartext in der Liste. Projekt-Ausnahmen liegen in ${ALLOW_FILE} (kann ins Repo), globale gelten überall. Claude kann die Liste nicht ändern.`,
369          `Exceptions let known test keys and example files pass. Entries: a path in gitignore style (tests/fixtures/**, *.example.ts) or sha256:<hash> of a value – keys never stand in clear text in the list. Project exceptions live in ${ALLOW_FILE} (can be committed), global ones apply everywhere. Claude cannot change the list.`,
370        )}
371      </Text>
372
373      <Box flexDirection="column" marginTop={1}>
374        <Text bold>{t('Zuletzt gefunden', 'Last finds')}</Text>
375        {finds.length === 0 ? <Text dimColor>{t('  Noch nichts.', '  Nothing yet.')}</Text> : null}
376        {finds.map((f, i) => (
377          <Box key={`sa-f-${i}`} flexDirection="column">
378            <Text>
379              <Text color={f.stopped ? 'red' : 'yellow'}>{`  ${f.stopped ? '✘' : '⚠'} ${f.what}`}</Text>
380              <Text dimColor>{`  ${f.path ?? f.where} · ${when(f.at)}`}</Text>
381            </Text>
382            <Box flexDirection="row" paddingLeft={4}>
383              {f.path ? <Button key={`sa-fp-${i}`} label={t('Datei erlauben (Projekt)', 'Allow file (project)')} plain onPress={() => void addEntry($, 'project', f.path!)} /> : null}
384              {f.path && f.hash ? <Text>{'  '}</Text> : null}
385              {f.hash ? <Button key={`sa-fv-${i}`} label={t('Wert erlauben (Projekt)', 'Allow value (project)')} plain onPress={() => void addEntry($, 'project', `sha256:${f.hash}  # ${f.what} ${f.path ?? f.where}`)} /> : null}
386              {f.hash ? <Text>{'  '}</Text> : null}
387              {f.hash ? <Button key={`sa-fg-${i}`} label={t('Wert erlauben (global)', 'Allow value (global)')} plain onPress={() => void addEntry($, 'global', `sha256:${f.hash}  # ${f.what}`)} /> : null}
388            </Box>
389          </Box>
390        ))}
391      </Box>
392
393      <Box flexDirection="column" marginTop={1}>
394        <Text bold>{t(`Projekt (${ALLOW_FILE})`, `Project (${ALLOW_FILE})`)}</Text>
395        {proj.filter(l => !l.trim().startsWith('#')).length === 0 ? <Text dimColor>{t('  keine', '  none')}</Text> : null}
396        {proj.map((l, i) => entryRow('project', l, i))}
397        {Input ? <Input key="sa-add-project" label={t('  Neu: ', '  New: ')} placeholder="tests/fixtures/**" submitLabel={t('Hinzufügen', 'Add')} onSubmit={(v: string) => void addEntry($, 'project', v)} /> : null}
398      </Box>
399
400      <Box flexDirection="column" marginTop={1}>
401        <Text bold>{t('Global (alle Projekte)', 'Global (all projects)')}</Text>
402        {glob.length === 0 ? <Text dimColor>{t('  keine', '  none')}</Text> : null}
403        {glob.map((l, i) => entryRow('global', l, i))}
404        {Input ? <Input key="sa-add-global" label={t('  Neu: ', '  New: ')} placeholder="*.example.env" submitLabel={t('Hinzufügen', 'Add')} onSubmit={(v: string) => void addEntry($, 'global', v)} /> : null}
405      </Box>
406
407      <Text dimColor>{placeholders ? t('Platzhalter (EXAMPLE, DUMMY, FAKE, xxxx …) werden automatisch durchgelassen.', 'Placeholders (EXAMPLE, DUMMY, FAKE, xxxx …) pass automatically.') : t('Platzhalter-Erkennung ist aus.', 'Placeholder detection is off.')}</Text>
408    </Box>
409  )
410}
411
mods/screen-mask/hooks/register.tsx 105 lines
1// Screen Mask: while you share your screen, the transcript hides API keys, tokens, private keys,
2// e-mail addresses, IP addresses, your login name in paths and any names you list. Only the
3// drawing changes: Claude still reads and writes the original text. Nothing leaves the machine.
4// /mask switches it; the band shows when it is on.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import { offer as offerBand } from '../../settings/hooks/band'
9import type { BandPart } from '../../settings/hooks/band'
10import { type MaskRules, maskDeep, maskText, parseTerms } from './mask'
11
12const isOn = atom({ plugin: 'mod-box', key: 'sm_on' } as const, false)
13// Kept across sessions: a restart in the middle of a screen share must not uncover anything.
14const STORE_ON = 'sm:on'
15
16let lang: 'de' | 'en' = 'en'
17const t = (de: string, en: string) => (lang === 'en' ? en : de)
18const rules: MaskRules = { terms: [], user: '', emails: true, ips: true }
19
20const mask = (s: string) => maskText(s, rules)
21
22async function setOn($: EngineInterface, on: boolean) {
23  await update($, isOn, () => on)
24  await $.store.set(STORE_ON, on).catch(() => {})
25  $.ui.toast(on ? t('🙈 Bildschirmschutz an: Geheimnisse und Namen sind in der Anzeige verdeckt.', '🙈 Screen mask on: secrets and names are hidden on screen.') : t('Bildschirmschutz aus.', 'Screen mask off.'))
26}
27
28export const register: Register = (on, options) => {
29  lang = options.language === 'de' ? 'de' : 'en'
30  rules.terms = parseTerms(String(options.terms ?? ''))
31  rules.emails = String(options.emails ?? 'on') !== 'off'
32  rules.ips = String(options.ips ?? 'on') !== 'off'
33
34  on('session.start', {}, async ($, e, next) => {
35    const r = await next(e)
36    rules.user = String((await $.env.get('USERNAME')) || (await $.env.get('USER')) || '')
37    await $.command.register({ name: 'mask', description: t('Bildschirmschutz: Geheimnisse und Namen in der Anzeige verdecken', 'Screen mask: hide secrets and names on screen'), argumentHint: '[on|off]' })
38    if ((await $.store.get(STORE_ON).catch(() => false)) === true) await update($, isOn, () => true)
39    return r
40  })
41
42  on('command.run', { command: 'mask' }, async ($, e) => {
43    const arg = String(e.args ?? '').trim().toLowerCase()
44    const want = arg === 'on' || arg === 'an' ? true : arg === 'off' || arg === 'aus' ? false : !(await read($, isOn))
45    await setOn($, want)
46    return { text: want ? t('Bildschirmschutz an.', 'Screen mask on.') : t('Bildschirmschutz aus.', 'Screen mask off.') }
47  })
48
49  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
50    if (!(await read($, isOn))) return next(e)
51    return next({ ...e, props: { ...e.props, text: mask(e.props.text) } })
52  })
53
54  on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
55    if (!(await read($, isOn))) return next(e)
56    return next({ ...e, props: { ...e.props, text: mask(e.props.text) } })
57  })
58
59  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
60    if (!(await read($, isOn))) return next(e)
61    const props = e.props as Record<string, unknown>
62    return next({ ...e, props: { ...e.props, input: maskDeep(props.input, mask), ...(props.output === undefined ? {} : { output: maskDeep(props.output, mask) }) } } as typeof e)
63  })
64
65  on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
66    if (!(await read($, isOn))) return next(e)
67    const props = e.props as Record<string, unknown>
68    return next({ ...e, props: { ...e.props, output: maskDeep(props.output, mask) } } as typeof e)
69  })
70
71  on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
72    if (!(await read($, isOn))) return next(e)
73    return next({ ...e, props: { ...e.props, calls: e.props.calls.map(c => ({ ...c, input: maskDeep((c as Record<string, unknown>).input, mask) })) } } as typeof e)
74  })
75
76  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
77    const rest = await next(e)
78    if (!e.props.hasSurvey) {
79      const part = await band($, e).catch(() => null)
80      if (part) offerBand(e, part)
81    }
82    return rest
83  })
84}
85
86export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
87  if (!(await read($, isOn))) return null
88  const { Text, Button } = $.ui.resolve(e)
89  const what = [t('Geheimnisse', 'secrets'), rules.emails ? t('E-Mails', 'e-mails') : '', rules.ips ? 'IPs' : '', rules.user ? t('Benutzername', 'login name') : '', rules.terms.length ? t(`${rules.terms.length} Begriffe`, `${rules.terms.length} terms`) : ''].filter(Boolean).join(', ')
90  return {
91    id: 'screenMask',
92    urgent: false,
93    short: <Text color="yellow">🙈</Text>,
94    line: (
95      <Text>
96        <Text color="yellow" bold>{'🙈 '}</Text>
97        <Text>{t('Bildschirmschutz an: ', 'Screen mask on: ')}</Text>
98        <Text dimColor>{what}</Text>
99      </Text>
100    ),
101    actions: [<Button key="sm-off" label={t('Aus', 'Off')} plain onPress={() => void setOn($, false)} />],
102    status: `🙈 ${t('Bildschirmschutz', 'Screen mask')}`,
103  }
104}
105
mods/package-guard/hooks/register.tsx 159 lines
1// Package Guard: before Claude installs (or runs, via npx and the like) a package from npm, PyPI or
2// crates.io, it asks the public registry about it. It holds the command when the package does not
3// exist (a made-up name), is only days old, or looks like a well-known package and is young or thin
4// (typosquatting). Everything else goes through untouched. The person allows a held package in
5// the band; Claude then installs it again.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { PackageHold, PackageInfo } from '../types'
10import { offer as offerBand } from '../../settings/hooks/band'
11import type { BandPart } from '../../settings/hooks/band'
12import { type Ecosystem, wantedPackages } from './parse'
13import { lookalike } from './similar'
14
15const hold = atom({ plugin: 'mod-box', key: 'pg_hold' } as const, null as PackageHold | null)
16const allowed = atom({ plugin: 'mod-box', key: 'pg_allowed' } as const, [] as string[])
17
18let lang: 'de' | 'en' = 'en'
19const t = (de: string, en: string) => (lang === 'en' ? en : de)
20let mode = 'block'
21const known = new Map<string, PackageInfo>()
22
23const DAY = 86_400_000
24const NEW_DAYS = 14
25const YOUNG_DAYS = 180
26const FEW_VERSIONS = 5
27
28/** Why a package is suspicious; empty when it is fine. Pure, for the tests. */
29export function reasonsFor(eco: Ecosystem, name: string, info: PackageInfo | null, now: number, de = false): string[] {
30  const tr = (a: string, b: string) => (de ? a : b)
31  const like = lookalike(eco, name)
32  if (!info || info.error) return like ? [tr(`ähnelt „${like}“ (Registry nicht erreichbar)`, `looks like "${like}" (registry not reachable)`)] : []
33  if (info.exists === false) return [tr('gibt es in der Registry nicht (erfundener Name?)', 'does not exist in the registry (a made-up name?)')]
34  // npm replaces removed malware with "0.0.1-security" from npm/security-holder.
35  if (/-security$/.test(info.latest ?? '') || /security-holder/.test(info.repo ?? '')) {
36    return [tr('wurde als Schadpaket entfernt (npm-Sicherheitsplatzhalter)', 'was removed as malware (npm security placeholder)')]
37  }
38  const out: string[] = []
39  const days = info.created ? Math.floor((now - Date.parse(info.created)) / DAY) : NaN
40  // a date in the future means a wrong clock: no age then
41  const age = Number.isFinite(days) && days >= 0 ? days : null
42  if (age !== null && age < NEW_DAYS) out.push(tr(`erst ${age} Tag(e) alt`, `only ${age} day(s) old`))
43  if (like && ((age !== null && age < YOUNG_DAYS) || (info.versions ?? 0) < FEW_VERSIONS)) {
44    out.push(tr(`ähnelt „${like}“ (${info.versions ?? '?'} Versionen, ${age ?? '?'} Tage alt)`, `looks like "${like}" (${info.versions ?? '?'} versions, ${age ?? '?'} days old)`))
45  }
46  return out
47}
48
49async function lookup($: EngineInterface, eco: Ecosystem, name: string): Promise<PackageInfo | null> {
50  const key = `${eco}:${name}`
51  const cached = known.get(key)
52  if (cached) return cached
53  const script = `${$.plugin.root}/mods/package-guard/registry.mjs`.replace(/\\/g, '/')
54  const r = await $.process.run(['node', script, eco, name], { timeoutMs: 15_000 }).catch(() => null)
55  let info: PackageInfo | null = null
56  try {
57    info = JSON.parse((r?.stdout ?? '').trim().split('\n').pop() || 'null') as PackageInfo | null
58  } catch {
59    info = null
60  }
61  if (info && !info.error) known.set(key, info)
62  return info
63}
64
65const label = (eco: Ecosystem) => (eco === 'npm' ? 'npm' : eco === 'pypi' ? 'PyPI' : 'crates.io')
66
67export const register: Register = (on, options) => {
68  lang = options.language === 'de' ? 'de' : 'en'
69  mode = String(options.mode ?? 'block')
70
71  on('session.start', {}, async ($, e, next) => {
72    const r = await next(e)
73    await $.command.register({ name: 'pkg-allow', description: t('Paket-Wächter: ein angehaltenes Paket erlauben', 'Package Guard: allow a held package'), argumentHint: '<name>' })
74    return r
75  })
76
77  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
78    const wanted = wantedPackages(String(e.command ?? ''))
79    if (!wanted.length) return next(e)
80    const ok = await read($, allowed)
81    const now = await $.clock.now()
82    const held: PackageHold['packages'] = []
83    for (const p of wanted) {
84      if (ok.includes(`${p.eco}:${p.name}`)) continue
85      const info = await lookup($, p.eco, p.name)
86      const reasons = reasonsFor(p.eco, p.name, info, now, lang === 'de')
87      if (reasons.length) held.push({ eco: p.eco, name: p.name, runs: p.runs, reasons, repo: info?.repo ?? null })
88    }
89    if (!held.length) return next(e)
90    const list = held.map(p => `${p.name} (${label(p.eco)}): ${p.reasons.join(', ')}`).join('; ')
91    // Without a registry answer a look-alike is only worth a warning.
92    const onlyUnsure = held.every(p => p.reasons.every(r => /registry not reachable|Registry nicht erreichbar/.test(r)))
93    if (mode === 'warn' || onlyUnsure) {
94      $.ui.toast(`📦 ${t('Paket-Wächter', 'Package Guard')}: ${list}`, { timeoutMs: 8000 })
95      return next(e)
96    }
97    await update($, hold, () => ({ command: String(e.command ?? '').slice(0, 300), packages: held, at: now, isDismissed: false }))
98    $.ui.toast(`📦 ${t('Paket-Wächter hat angehalten', 'Package Guard held')}: ${list}`, { timeoutMs: 8000 })
99    return {
100      deny:
101        `Package Guard held this command and did not run it: ${list}. ` +
102        'Check the name: it may be a typo of a well-known package or a package that does not exist. ' +
103        'Tell the user what you found. If they confirm the package, they allow it in the band above the prompt (or with /pkg-allow <name>); then run the command again. Do not install it another way.',
104    }
105  })
106
107  on('command.run', { command: 'pkg-allow' }, async ($, e) => {
108    const name = String(e.args ?? '').trim().toLowerCase()
109    const h = await read($, hold)
110    const hits = (h?.packages ?? []).filter(p => !name || p.name === name)
111    if (!hits.length) return { text: t('Kein angehaltenes Paket dieses Namens.', 'No held package by that name.') }
112    await allow($, hits.map(p => `${p.eco}:${p.name}`))
113    return { text: t(`Erlaubt: ${hits.map(p => p.name).join(', ')}. Claude kann den Befehl jetzt erneut ausführen.`, `Allowed: ${hits.map(p => p.name).join(', ')}. Claude can run the command again now.`) }
114  })
115
116  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
117    const rest = await next(e)
118    if (!e.props.hasSurvey) {
119      const part = await band($, e).catch(() => null)
120      if (part) offerBand(e, part)
121    }
122    return rest
123  })
124}
125
126async function allow($: EngineInterface, keys: string[]) {
127  await update($, allowed, list => [...new Set([...list, ...keys])])
128  await update($, hold, h => {
129    if (!h) return h
130    const rest = h.packages.filter(p => !keys.includes(`${p.eco}:${p.name}`))
131    return rest.length ? { ...h, packages: rest } : null
132  })
133}
134
135export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
136  const h = await read($, hold)
137  if (!h || h.isDismissed || !h.packages.length) return null
138  const { Text, Button } = $.ui.resolve(e)
139  const names = h.packages.map(p => `${p.name} (${label(p.eco)}): ${p.reasons.join(', ')}`).join(' · ')
140  const keys = h.packages.map(p => `${p.eco}:${p.name}`)
141  return {
142    id: 'packageGuard',
143    urgent: true,
144    short: <Text color="yellow">{`📦 ${h.packages.length}`}</Text>,
145    line: (
146      <Text>
147        <Text color="yellow" bold>{'📦 '}</Text>
148        <Text>{t('Paket angehalten: ', 'Package held: ')}</Text>
149        <Text dimColor>{names}</Text>
150      </Text>
151    ),
152    actions: [
153      <Button key="pg-allow" label={t('Erlauben', 'Allow')} plain onPress={() => void allow($, keys).then(() => $.ui.toast(t('Erlaubt. Claude kann den Befehl jetzt erneut ausführen.', 'Allowed. Claude can run the command again now.')))} />,
154      <Button key="pg-dismiss" label="×" plain onPress={() => void update($, hold, x => (x ? { ...x, isDismissed: true } : x))} />,
155    ],
156    status: `📦 ${names}`,
157  }
158}
159
mods/ci-watch/hooks/register.tsx 453 lines
1// CI watch: "A push you don't check on doesn't count as done."
2// After every successful `git push` (Bash tool) it polls the GitHub Actions runs for the
3// pushed commit via gh until all are done. A band above the prompt shows the status;
4// on red it fetches `gh run view --log-failed`, opens a window with the log and/or puts
5// an analysis request into the prompt (nothing is sent by itself). /ci shows the status.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register, Timer } from 'claude-code'
8
9import type { CiRun, CiWatch } from '../types'
10import { offer as offerBand } from '../../settings/hooks/band'
11import type { BandPart } from '../../settings/hooks/band'
12
13const watch = atom({ plugin: 'mod-box', key: 'ci_watch' } as const, null as CiWatch | null)
14const PANE = 'ci-watch'
15const NO_RUNS_AFTER_MS = 120_000 // how long GitHub may take to create a run
16const GIVE_UP_AFTER_MS = 3 * 60 * 60_000
17const LOG_LINES = 200
18
19export const PUSH = /(?:^|[;&|(]\s*)git\s+(?:-C\s+(\S+)\s+)?push\b/
20
21// Settings and timers at module level: the validator requires $ to be passed only to
22// top-level functions.
23let intervalMs = 20_000
24let onRed = 'both'
25let withSound = false
26let notifyOn = 'red'
27let prOnDesktop = 'app'
28let onDesktop = false
29
30export const PR_CREATE = /(?:^|[;&|(]\s*)gh\s+pr\s+create\b/
31export const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/(\d+)/
32
33/** owner/repo of a pull request's URL, for gh -R; '' for anything else. Pure. */
34export function repoOf(url: string): string {
35  return /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/pull\/\d+/.exec(url)?.[1] ?? ''
36}
37
38// gh finds a pull request by its URL from any folder; by its number only inside its repository
39// (a PR made from another folder or a worktree is watched from the session's folder).
40const prRef = (pr: { number: number; url: string }) => pr.url || String(pr.number)
41// Display language (options.language: de | en)
42let lang: 'de' | 'en' = 'en'
43const t = (de: string, en: string) => (lang === 'en' ? en : de)
44let timer: Timer | null = null
45
46function stop() {
47  timer?.cancel()
48  timer = null
49}
50
51function start($: EngineInterface) {
52  stop()
53  timer = $.clock.every(intervalMs, () => void poll($).catch(() => {}))
54  void poll($).catch(() => {})
55}
56
57async function poll($: EngineInterface) {
58  const w = await read($, watch)
59  if (!w || (w.phase !== 'warte' && w.phase !== 'laeuft')) return stop()
60  const now = await $.clock.now()
61  const fetched = w.pr ? await prChecks($, w) : await commitRuns($, w)
62  if (typeof fetched === 'string') {
63    await set($, x => ({ ...x, phase: 'fehler', message: fetched, endedAt: now }))
64    return stop()
65  }
66  const runs = fetched
67  if (runs.length === 0) {
68    if (now - w.startedAt > NO_RUNS_AFTER_MS) {
69      await set($, x => ({ ...x, phase: 'keine', message: t('Kein Actions-Lauf zu diesem Commit.', 'No Actions run for this commit.'), endedAt: now }))
70      stop()
71    }
72    return
73  }
74  const open = runs.filter(x => x.status !== 'completed')
75  if (open.length > 0) {
76    await set($, x => ({ ...x, phase: 'laeuft', runs }))
77    if (now - w.startedAt > GIVE_UP_AFTER_MS) {
78      await set($, x => ({ ...x, phase: 'fehler', message: t('Nach 3 Stunden nicht fertig; Überwachung beendet.', 'Not finished after 3 hours; stopped watching.'), endedAt: now }))
79      stop()
80    }
81    return
82  }
83  const bad = runs.find(x => !['success', 'skipped', 'neutral'].includes(x.conclusion))
84  stop()
85  if (!bad) {
86    const merge = w.pr ? await mergeState($, w) : ''
87    await set($, x => ({ ...x, phase: 'gruen', runs, endedAt: now, message: '', pr: x.pr ? { ...x.pr, merge } : null }))
88    $.ui.toast(
89      w.pr
90        ? t(`PR #${w.pr.number}: alle ${runs.length} Checks grün${merge ? ` · ${merge}` : ''}`, `PR #${w.pr.number}: all ${runs.length} checks green${merge ? ` · ${merge}` : ''}`)
91        : t(`CI grün: ${runs.length} Lauf/Läufe für ${w.sha.slice(0, 7)}`, `CI green: ${runs.length} run(s) for ${w.sha.slice(0, 7)}`),
92    )
93    void sound($, 'green')
94    void notify($, 'green', w.pr ? `PR #${w.pr.number}: ${t('alle Checks grün', 'all checks green')}` : `${w.branch} ${w.sha.slice(0, 7)}: ${t('alle Läufe grün', 'all runs green')}`)
95    return
96  }
97  const log = bad.id ? await $.process.run(['gh', 'run', 'view', String(bad.id), '--log-failed', ...(w.pr && repoOf(w.pr.url) ? ['-R', repoOf(w.pr.url)] : [])], { cwd: w.cwd, timeoutMs: 60_000 }).catch(() => null) : null
98  const tail = (log?.stdout ?? '').split('\n').slice(-LOG_LINES).join('\n').trim()
99  const noLog = bad.id
100    ? t('(kein Log erhalten)', '(no log received)')
101    : t(`(Check außerhalb von GitHub Actions: kein Log über gh. Details: ${bad.url})`, `(check outside GitHub Actions: no log through gh. Details: ${bad.url})`)
102  await set($, x => ({ ...x, phase: 'rot', runs, endedAt: now, failed: { id: bad.id, name: bad.name, url: bad.url, log: tail || noLog } }))
103  void sound($, 'red')
104  void notify($, 'red', `${bad.name} (${bad.conclusion}) · ${w.pr ? `PR #${w.pr.number}` : w.branch}`)
105  $.ui.toast(t(`CI rot: ${bad.name} (${bad.conclusion}). Analysieren, bevor irgendetwas anderes passiert.`, `CI red: ${bad.name} (${bad.conclusion}). Analyse it before anything else happens.`))
106  if (onRed === 'pane' || onRed === 'both') void $.ui.open({ id: PANE, title: `${t('CI rot', 'CI red')} · ${bad.name}`, focus: true, closeOnEscape: true }).catch(() => {})
107  if (onRed === 'prompt' || onRed === 'both') void $.prompt.fill({ text: analyse(w, bad), mode: 'replace' }).catch(() => {})
108}
109
110async function startAfterPush($: EngineInterface, dashC: string | undefined, command: string) {
111  if (await begin($, dashC, command)) start($)
112}
113
114function ghError(stderr: string): string {
115  return /not found|nicht gefunden|ENOENT|cannot find/i.test(stderr)
116    ? t('gh ist nicht installiert.', 'gh is not installed.')
117    : /auth|login/i.test(stderr)
118      ? t('gh ist nicht angemeldet (gh auth login).', 'gh is not signed in (gh auth login).')
119      : stderr.trim().split('\n')[0] || t('gh-Fehler', 'gh error')
120}
121
122// The Actions runs of the pushed commit; a message when gh fails.
123async function commitRuns($: EngineInterface, w: CiWatch): Promise<CiRun[] | string> {
124  const r = await $.process
125    .run(['gh', 'run', 'list', '--commit', w.sha, '--json', 'databaseId,name,workflowName,status,conclusion,url', '--limit', '30'], { cwd: w.cwd, timeoutMs: 30_000 })
126    .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
127  if (r.exitCode !== 0) return ghError(r.stderr)
128  return (JSON.parse(r.stdout || '[]') as Array<Record<string, unknown>>).map(x => ({
129    id: Number(x.databaseId),
130    name: String(x.workflowName || x.name || 'Lauf'),
131    status: String(x.status ?? ''),
132    conclusion: String(x.conclusion ?? ''),
133    url: String(x.url ?? ''),
134  }))
135}
136
137/** gh pr checks --json: bucket pass|fail|pending|skipping|cancel; exit 8 while pending, 1 on failure. */
138export function parseChecks(stdout: string): CiRun[] {
139  const list = JSON.parse(stdout || '[]') as Array<Record<string, unknown>>
140  return list.map(x => {
141    const bucket = String(x.bucket ?? '')
142    const link = String(x.link ?? '')
143    const run = /\/actions\/runs\/(\d+)/.exec(link)
144    return {
145      id: run ? Number(run[1]) : 0,
146      name: x.workflow ? `${String(x.workflow)} / ${String(x.name ?? '')}` : String(x.name ?? 'Check'),
147      status: bucket === 'pending' ? 'in_progress' : 'completed',
148      conclusion: bucket === 'pass' ? 'success' : bucket === 'skipping' ? 'skipped' : bucket === 'fail' ? 'failure' : bucket === 'cancel' ? 'cancelled' : bucket,
149      url: link,
150    }
151  })
152}
153
154// The checks of a pull request: Actions and every other check (other CI, bots).
155async function prChecks($: EngineInterface, w: CiWatch): Promise<CiRun[] | string> {
156  const r = await $.process
157    .run(['gh', 'pr', 'checks', prRef(w.pr!), '--json', 'name,state,bucket,link,workflow'], { cwd: w.cwd, timeoutMs: 30_000 })
158    .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
159  if (r.stdout.trim().startsWith('[')) return parseChecks(r.stdout)
160  if (/no checks reported/i.test(r.stderr)) return []
161  return ghError(r.stderr)
162}
163
164// After green: can the PR be merged? (mergeStateStatus and reviewDecision of gh pr view)
165async function mergeState($: EngineInterface, w: CiWatch): Promise<string> {
166  const r = await $.process
167    .run(['gh', 'pr', 'view', prRef(w.pr!), '--json', 'mergeStateStatus,reviewDecision,isDraft,state'], { cwd: w.cwd, timeoutMs: 30_000 })
168    .catch(() => null)
169  if (!r || r.exitCode !== 0) return ''
170  return mergeText(JSON.parse(r.stdout || '{}'))
171}
172
173export function mergeText(v: { mergeStateStatus?: string; reviewDecision?: string; isDraft?: boolean; state?: string }): string {
174  if (v.state && v.state !== 'OPEN') return v.state === 'MERGED' ? t('schon gemergt', 'already merged') : t('geschlossen', 'closed')
175  if (v.isDraft) return t('noch Entwurf', 'still a draft')
176  if (v.reviewDecision === 'CHANGES_REQUESTED') return t('Änderungen angefordert', 'changes requested')
177  if (v.reviewDecision === 'REVIEW_REQUIRED') return t('wartet auf Review', 'waiting for review')
178  switch (v.mergeStateStatus) {
179    case 'CLEAN':
180    case 'HAS_HOOKS':
181      return t('bereit zum Mergen', 'ready to merge')
182    case 'DIRTY':
183      return t('Merge-Konflikte', 'merge conflicts')
184    case 'BEHIND':
185      return t('Branch ist hinter der Basis', 'branch is behind its base')
186    case 'BLOCKED':
187      return t('blockiert (Regeln des Repos)', 'blocked (repo rules)')
188    case 'UNSTABLE':
189      return t('nicht alle Pflicht-Checks grün', 'not all required checks green')
190    default:
191      return ''
192  }
193}
194
195// Starts watching a pull request: its number or URL, in the folder cwd.
196async function beginPr($: EngineInterface, ref: string, cwd: string): Promise<boolean> {
197  // No ref: the pull request of the current branch.
198  const r = await $.process.run(['gh', 'pr', 'view', ...(ref ? [ref] : []), '--json', 'number,url,title,headRefName,headRefOid,state'], { cwd, timeoutMs: 30_000 }).catch(() => null)
199  if (!r || r.exitCode !== 0) return false
200  const v = JSON.parse(r.stdout || '{}') as { number?: number; url?: string; title?: string; headRefName?: string; headRefOid?: string; state?: string }
201  if (!v.number || v.state !== 'OPEN') return false
202  const now = await $.clock.now()
203  await update($, watch, (): CiWatch => ({
204    sha: String(v.headRefOid ?? ''),
205    branch: String(v.headRefName ?? '?'),
206    cwd,
207    startedAt: now,
208    endedAt: 0,
209    phase: 'warte',
210    runs: [],
211    failed: null,
212    message: '',
213    isDismissed: false,
214    pr: { number: Number(v.number), url: String(v.url ?? ''), title: String(v.title ?? ''), merge: '' },
215  }))
216  return true
217}
218
219async function startPr($: EngineInterface, ref: string, cwd: string): Promise<boolean> {
220  const ok = await beginPr($, ref, cwd)
221  if (ok) start($)
222  return ok
223}
224
225// A system notification at the end (option notify: off | red | always), through the person's own
226// notification channel. Claude Code notifies when a turn ends; a CI run usually ends later.
227async function notify($: EngineInterface, kind: 'green' | 'red', text: string) {
228  if (notifyOn === 'off' || (notifyOn === 'red' && kind === 'green')) return
229  await $.ui.notify(text, { title: kind === 'red' ? t('CI rot', 'CI red') : t('CI grün', 'CI green') }).catch(() => undefined)
230}
231
232// A short sound at the end (option): Windows system sounds through PowerShell, elsewhere the mod's own clip.
233async function sound($: EngineInterface, kind: 'green' | 'red') {
234  if (!withSound) return
235  if ((await $.env.get('OS')) === 'Windows_NT') {
236    const s = kind === 'green' ? 'Asterisk' : 'Hand'
237    await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', `[System.Media.SystemSounds]::${s}.Play(); Start-Sleep -Milliseconds 900`], { timeoutMs: 10_000 }).catch(() => {})
238  } else {
239    await $.audio.play({ asset: `mods/ci-watch/sounds/${kind}.wav` }).catch(() => {})
240  }
241}
242
243export const register: Register = (on, options) => {
244  // Band manager: hand this mod’s display to the Mod Box (it draws, orders and places it).
245  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
246    const rest = await next(e)
247    if (!e.props.hasSurvey) {
248      const part = await band($, e).catch(() => null)
249      if (part) offerBand(e, part)
250    }
251    return rest
252  })
253
254  intervalMs = Math.max(5, Number(options.intervalSec) || 20) * 1000
255  onRed = String(options.onRed || 'both')
256  withSound = options.sound === 'on'
257  notifyOn = String(options.notify || 'red')
258  prOnDesktop = String(options.prOnDesktop || 'app')
259  lang = options.language === 'de' ? 'de' : 'en'
260
261  on('session.start', {}, async ($, e, next) => {
262    const r = await next(e)
263    onDesktop = (e as { surface?: string | null }).surface === 'desktop'
264    await $.command.register({
265      name: 'ci',
266      description: t('CI-Wächter: Stand der Läufe zum letzten Push oder PR', 'CI Watch: state of the runs for the last push or PR'),
267      argumentHint: '[pr <nr|url> | stop]',
268    })
269    // Watching continues after a reload.
270    const w = await read($, watch)
271    if (w && (w.phase === 'warte' || w.phase === 'laeuft')) start($)
272    return r
273  })
274
275  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
276    const result = await next(e)
277    if (('deny' in result && result.deny !== undefined) || result.isError) return result
278    const cmd = String(e.command ?? '')
279    // A new pull request: its URL stands in the output of gh pr create.
280    if (PR_CREATE.test(cmd)) {
281      const url = PR_URL.exec(String(result.text ?? ''))?.[0]
282      // The desktop app binds a new PR to the session and watches its checks itself (CI bar, auto-fix).
283      if (url && onDesktop && prOnDesktop === 'app') $.ui.toast(t('PR-Checks überwacht die Desktop-App (CI-Leiste); /ci pr zeigt sie hier.', 'The desktop app watches the PR checks (CI bar); /ci pr shows them here.'))
284      else if (url) void startPr($, url, await $.session.cwd()).catch(() => {})
285      return result
286    }
287    const m = PUSH.exec(cmd)
288    if (!m) return result
289    if (/\bpush\b[^;&|]*(?:--dry-run|\s-n\b)/.test(cmd)) return result
290    void startAfterPush($, m[1], cmd).catch(() => {})
291    return result
292  })
293
294  on('command.run', { command: 'ci' }, async ($, e) => {
295    const args = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
296    if (args[0] === 'stop') {
297      stop()
298      await update($, watch, () => null)
299      return { text: t('CI-Wächter: Beobachtung beendet.', 'CI Watch: stopped watching.') }
300    }
301    if (args[0] === 'pr') {
302      const ref = args[1] ?? ''
303      const ok = ref ? await startPr($, ref, await $.session.cwd()) : await startPr($, '', await $.session.cwd())
304      return {
305        text: ok
306          ? t(`CI-Wächter: beobachte PR ${ref || '(Branch)'}.`, `CI Watch: watching PR ${ref || '(branch)'}.`)
307          : t('CI-Wächter: kein offener PR gefunden (/ci pr <Nummer oder URL>).', 'CI Watch: no open PR found (/ci pr <number or URL>).'),
308      }
309    }
310    const w = await read($, watch)
311    if (!w) return { text: t('CI-Wächter: noch kein Push in dieser Sitzung. /ci pr <Nummer> beobachtet einen PR.', 'CI Watch: no push in this session yet. /ci pr <number> watches a PR.') }
312    if (w.phase === 'rot') await $.ui.open({ id: PANE, title: `${t('CI rot', 'CI red')} · ${w.failed?.name ?? ''}`, focus: true, closeOnEscape: true })
313    await update($, watch, x => (x ? { ...x, isDismissed: false } : x))
314    return { text: `${t('CI-Wächter', 'CI Watch')}: ${summary(w)}` }
315  })
316
317  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
318    const { Box, Text, Button, Code } = $.ui.resolve(e) as any
319    const w = await read($, watch)
320    const f = w?.failed
321    if (!w || !f) return <Text dimColor>{t('Kein roter Lauf.', 'No red run.')}</Text>
322    const rows = Math.max(5, (e.viewport?.rows ?? 30) - 8)
323    return (
324      <Box flexDirection="column" paddingX={1}>
325        <Text>
326          <Text color="red" bold>{`✘ ${f.name}`}</Text>
327          <Text dimColor>{w.pr ? `  ·  PR #${w.pr.number} ${w.pr.title}` : `  ·  ${w.branch}@${w.sha.slice(0, 7)}`}</Text>
328        </Text>
329        <Text dimColor>{f.url}</Text>
330        <Box marginTop={1} flexDirection="column">
331          {Code ? <Code source={f.log.split('\n').slice(-rows).join('\n')} wrap="truncate-end" /> : <Text>{f.log.split('\n').slice(-rows).join('\n')}</Text>}
332        </Box>
333        <Box flexDirection="row" marginTop={1}>
334          <Button key="ci-p-analyse" label={t('Analysieren lassen', 'Have it analysed')} variant="primary" autoFocus onPress={() => void $.prompt.fill({ text: analyse(w, f), mode: 'replace' }).then(() => $.ui.close({ id: PANE }))} />
335          <Text>{'  '}</Text>
336          <Button key="ci-p-kopieren" label={t('Log kopieren', 'Copy log')} onPress={(press: { surface?: string }) => void $.ui.copy({ text: f.log, surface: press.surface as any })} />
337          <Text>{'  '}</Text>
338          <Button key="ci-p-zu" label={t('Schließen', 'Close')} role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
339        </Box>
340      </Box>
341    )
342  })
343}
344
345async function set($: EngineInterface, fn: (w: CiWatch) => CiWatch) {
346  await update($, watch, w => (w ? fn(w) : w))
347}
348
349async function begin($: EngineInterface, dashC: string | undefined, command: string): Promise<boolean> {
350  const base = await $.session.cwd()
351  const cd = /(?:^|[;&|]\s*)cd\s+("[^"]+"|'[^']+'|\S+)\s*&&[^;|]*git\s+(?:-C\s+\S+\s+)?push/.exec(command)?.[1]?.replace(/^["']|["']$/g, '')
352  const dir = dashC ?? cd
353  const cwd = dir ? (/^([a-zA-Z]:[\\/]|\/)/.test(dir) ? dir : `${base}/${dir}`) : base
354  const head = await $.process.run(['git', 'rev-parse', 'HEAD'], { cwd }).catch(() => null)
355  if (!head || head.exitCode !== 0) return false
356  const branch = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd }).catch(() => null)
357  // Has the branch an open pull request? Then its checks count (also other CI and bots).
358  if (await beginPr($, branch?.stdout.trim() || '', cwd)) return true
359  const now = await $.clock.now()
360  await update($, watch, (): CiWatch => ({
361    sha: head.stdout.trim(),
362    branch: branch?.stdout.trim() || '?',
363    cwd,
364    startedAt: now,
365    endedAt: 0,
366    phase: 'warte',
367    runs: [],
368    failed: null,
369    message: '',
370    isDismissed: false,
371    pr: null,
372  }))
373  return true
374}
375
376export function summary(w: CiWatch): string {
377  const ref = w.pr ? `PR #${w.pr.number}` : `${w.branch}@${w.sha.slice(0, 7)}`
378  const took = w.endedAt && w.startedAt ? ` in ${fmt(w.endedAt - w.startedAt)}` : ''
379  switch (w.phase) {
380    case 'warte':
381      return `${ref}: ${t('warte auf GitHub Actions …', 'waiting for GitHub Actions …')}`
382    case 'laeuft': {
383      const done = w.runs.filter(r => r.status === 'completed').length
384      return `${ref}: ${t(`${w.runs.length - done} laufen, ${done} fertig`, `${w.runs.length - done} running, ${done} done`)} (${w.runs.map(r => r.name).join(', ')})`
385    }
386    case 'gruen':
387      return w.pr
388        ? `${ref}: ${t(`${w.runs.length} Checks grün`, `${w.runs.length} checks green`)}${took}${w.pr.merge ? ` · ${w.pr.merge}` : ''}`
389        : `${ref}: ${t(`grün, ${w.runs.length} Lauf/Läufe`, `green, ${w.runs.length} run(s)`)}${took}`
390    case 'rot':
391      return `${ref}: ${t('rot in', 'red in')} ${w.failed?.name ?? '?'}${took}`
392    case 'keine':
393      return `${ref}: ${w.message}`
394    case 'fehler':
395      return `${ref}: ${w.message}`
396  }
397}
398
399function fmt(ms: number): string {
400  const s = Math.round(ms / 1000)
401  return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
402}
403
404// The request Claude gets is English; only the answer follows the language setting.
405export function analyse(w: CiWatch, f: { id: number; name: string; url: string }): string {
406  const what = w.pr ? `PR #${w.pr.number} (${w.pr.url})` : `${w.branch}@${w.sha.slice(0, 7)}`
407  const answerIn = lang === 'en' ? '' : ' Answer in German.'
408  if (!f.id) {
409    // A check outside GitHub Actions: no log through gh, only its link.
410    return `CI is red for ${what}: check "${f.name}" (${f.url}) failed; it does not run in GitHub Actions. Open its details, name the cause and whether it lies in the code, in the setup or in something external, then propose the fix before anything else happens.${answerIn}`
411  }
412  return (
413    `CI is red: run "${f.name}" for ${w.branch}@${w.sha.slice(0, 7)} (${f.url}). ` +
414    `Read the logs of the failed step (gh run view ${f.id} --log-failed), name the cause ` +
415    'and say whether it lies in the code, in the fixture or in something external. If external: should the test withstand it? ' +
416    `Then propose the fix, before anything else happens.${answerIn}`
417  )
418}
419
420// For the Mod Box band manager (instead of an AbovePrompt hook of its own).
421export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
422  const w = await read($, watch)
423  if (!w || w.isDismissed) return null
424  const { Text, Button } = $.ui.resolve(e)
425  const color = w.phase === 'gruen' ? 'green' : w.phase === 'rot' ? 'red' : w.phase === 'fehler' ? 'yellow' : 'cyan'
426  const icon = w.phase === 'gruen' ? '✔' : w.phase === 'rot' ? '✘' : w.phase === 'fehler' || w.phase === 'keine' ? '·' : '⏳'
427  const isDone = w.phase !== 'warte' && w.phase !== 'laeuft'
428  const actions: unknown[] = []
429  if (w.phase === 'rot') {
430    actions.push(<Button key="ci-log" label="Log" plain onPress={() => void $.ui.open({ id: PANE, title: `${t('CI rot', 'CI red')} · ${w.failed?.name ?? ''}`, focus: true, closeOnEscape: true })} />)
431    if (w.failed) actions.push(<Button key="ci-analyse" label={t('Analysieren', 'Analyse')} plain onPress={() => void $.prompt.fill({ text: analyse(w, w.failed!), mode: 'replace' })} />)
432  }
433  if (isDone) actions.push(<Button key="ci-weg" label="×" plain onPress={() => void update($, watch, x => (x ? { ...x, isDismissed: true } : x))} />)
434  return {
435    id: 'ciWatch',
436    urgent: w.phase === 'rot' || w.phase === 'fehler',
437    short: (
438      <Text>
439        <Text color={color} bold>{`CI ${icon}`}</Text>
440        <Text dimColor>{` ${w.pr ? `#${w.pr.number} ` : ''}${w.phase === 'laeuft' ? `${w.runs.filter(r => r.status === 'completed').length}/${w.runs.length}` : w.phase === 'gruen' ? (w.pr?.merge || t('grün', 'green')) : w.phase === 'warte' ? '…' : w.pr ? '' : w.branch}`}</Text>
441      </Text>
442    ),
443    line: (
444      <Text>
445        <Text color={color} bold>{`CI ${icon} `}</Text>
446        <Text>{summary(w)}</Text>
447      </Text>
448    ),
449    actions,
450    status: `CI ${icon} ${summary(w)}`,
451  }
452}
453
mods/sign-guard/hooks/register.tsx 188 lines
1// Sign guard: sign the new binaries after a Windows build.
2// Detects build commands in the Bash tool (cargo --release, dotnet publish, go build, pyinstaller,
3// tauri build, electron-builder, msbuild, npm/pnpm/yarn run dist|build:win|package) and then looks
4// under the working folder for every .exe/.msi/.msix created during the build.
5// Mode "fragen" (ask): band with a button. Mode "auto": sign right away.
6// Signing uses your own script (option script, called with -Exe <file>). Already signed
7// files are skipped: with option subject only those signed by that certificate, otherwise any validly signed one.
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { SignFile, SignJob } from '../types'
12import { offer as offerBand } from '../../settings/hooks/band'
13import type { BandPart } from '../../settings/hooks/band'
14
15const job = atom({ plugin: 'mod-box', key: 'sg_job' } as const, null as SignJob | null)
16const busy = atom({ plugin: 'mod-box', key: 'sg_busy' } as const, false)
17
18const BINARY = /\.(exe|msi|msix)$/i
19// Folders holding intermediate output or third-party files.
20const SKIP = new Set(['node_modules', '.git', 'deps', 'incremental', '.fingerprint', 'build', 'obj', 'examples', '.venv', 'venv', '__pycache__', 'vendor', '.cargo'])
21const MAX_DEPTH = 7
22const MAX_ENTRIES = 8000
23// For band(): the script path from the options.
24let scriptOption = ''
25// Certificate whose signature counts as "already done" (empty: any valid signature).
26let subject = ''
27
28// Display language (options.language: de | en)
29let lang: 'de' | 'en' = 'en'
30const t = (de: string, en: string) => (lang === 'en' ? en : de)
31
32export const BUILD =
33  /\b(cargo\s+(?:build|tauri\s+build)[^;&|]*--release|dotnet\s+publish|go\s+build|pyinstaller|tauri\s+build|electron-builder|msbuild|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dist|make|package|build:win|tauri\s+build)\b)/i
34
35export const register: Register = (on, options) => {
36  // Band manager: hand this mod’s display to the Mod Box (it draws, orders and places it).
37  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
38    const rest = await next(e)
39    if (!e.props.hasSurvey) {
40      const part = await band($, e).catch(() => null)
41      if (part) offerBand(e, part)
42    }
43    return rest
44  })
45
46  const auto = options.mode === 'auto'
47  const scriptOpt = String(options.script ?? '').trim()
48  scriptOption = scriptOpt
49  subject = String(options.subject ?? '').trim()
50  lang = options.language === 'de' ? 'de' : 'en'
51
52  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
53    const cmd = String(e.command ?? '')
54    const m = BUILD.exec(cmd)
55    if (!m) return next(e)
56    const startedAt = (await $.clock.now()) - 2000
57    const result = await next(e)
58    if (('deny' in result && result.deny !== undefined) || result.isError) return result
59    if ((await $.env.get('OS')) !== 'Windows_NT') return result
60    void found($, m[1] ?? m[0], startedAt).then(async j => {
61      if (j && auto) await signAll($, scriptOpt)
62    }).catch(() => {})
63    return result
64  })
65
66}
67
68async function found($: EngineInterface, build: string, since: number): Promise<SignJob | null> {
69  const cwd = await $.session.cwd()
70  const files: string[] = []
71  let seen = 0
72  const walk = async (dir: string, depth: number): Promise<void> => {
73    if (depth > MAX_DEPTH || seen > MAX_ENTRIES) return
74    const entries = await $.fs.list(dir).catch(() => [])
75    for (const x of entries) {
76      seen++
77      const p = `${dir}/${x.name}`
78      if (x.kind === 'dir') {
79        if (!SKIP.has(x.name) && !x.name.startsWith('.')) await walk(p, depth + 1)
80      } else if (x.kind === 'file' && BINARY.test(x.name) && x.mtimeMs >= since) {
81        files.push(p)
82      }
83    }
84  }
85  await walk(cwd, 0)
86  if (files.length === 0) return null
87  const signed = await Promise.all(files.map(f => isOurs($, f)))
88  const todo = files.filter((_, i) => !signed[i])
89  if (todo.length === 0) return null
90  const j: SignJob = {
91    build: build.trim().slice(0, 40),
92    cwd,
93    at: await $.clock.now(),
94    files: todo.map(path => ({ path, ok: null, message: '' })),
95    phase: 'gefunden',
96    isDismissed: false,
97  }
98  await update($, job, () => j)
99  return j
100}
101
102async function scriptPath($: EngineInterface, opt: string): Promise<string> {
103  if (opt) return opt
104  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
105  return `${home}\\.claude\\skills\\sign-binary\\sign.ps1`
106}
107
108async function isOurs($: EngineInterface, file: string): Promise<boolean> {
109  const r = await $.process
110    .run(['powershell', '-NoProfile', '-NonInteractive', '-Command', `$s = Get-AuthenticodeSignature -LiteralPath '${file.replace(/'/g, "''")}'; "$($s.Status)|$($s.SignerCertificate.Subject)"`], { timeoutMs: 20_000 })
111    .catch(() => null)
112  if (!r || r.exitCode !== 0) return false
113  const [status = '', who = ''] = r.stdout.trim().split('|')
114  return subject ? who.includes(subject) : status === 'Valid'
115}
116
117async function signAll($: EngineInterface, opt: string) {
118  const j = await read($, job)
119  if (!j || (await read($, busy))) return
120  const script = await scriptPath($, opt)
121  if (!(await $.fs.exists(script))) {
122    await update($, job, (x): SignJob | null => (x ? { ...x, phase: 'fehler', files: x.files.map(f => ({ ...f, ok: false, message: t(`Skript fehlt: ${script}`, `script missing: ${script}`) })) } : x))
123    $.ui.toast(t(`Signier-Wächter: ${script} nicht gefunden`, `Sign Guard: ${script} not found`))
124    return
125  }
126  await update($, busy, () => true)
127  const results: SignFile[] = []
128  try {
129    for (const f of j.files) {
130      const r = await $.process
131        .run(['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', script, '-Exe', f.path], { cwd: j.cwd, timeoutMs: 120_000 })
132        .catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
133      const text = (r.exitCode === 0 ? r.stdout : r.stderr || r.stdout).trim().split('\n').filter(Boolean).pop() ?? ''
134      results.push({ path: f.path, ok: r.exitCode === 0, message: text.slice(0, 300) })
135    }
136  } finally {
137    await update($, busy, () => false)
138  }
139  const good = results.filter(r => r.ok).length
140  await update($, job, (x): SignJob | null => (x ? { ...x, files: results, phase: good === results.length ? 'signiert' : good > 0 ? 'teilweise' : 'fehler' } : x))
141  $.ui.toast(t(`Signier-Wächter: ${good} von ${results.length} signiert`, `Sign Guard: ${good} of ${results.length} signed`))
142}
143
144function base(p: string): string {
145  return p.split(/[\\/]/).pop() ?? p
146}
147
148// For the Mod Box band manager (instead of an AbovePrompt hook of its own).
149export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
150  const j = await read($, job)
151  if (!j || j.isDismissed) return null
152  const isBusy = await read($, busy)
153  const { Text, Button } = $.ui.resolve(e)
154  const names = j.files.map(f => `${f.ok === true ? '✔' : f.ok === false ? '✘' : '·'} ${base(f.path)}`).join('  ')
155  const head =
156    isBusy ? t('signiere …', 'signing …')
157    : j.phase === 'gefunden' ? t(`${j.files.length} neue Binärdatei(en) nach ${j.build}:`, `${j.files.length} new binary file(s) after ${j.build}:`)
158    : j.phase === 'signiert' ? t('signiert:', 'signed:')
159    : j.phase === 'teilweise' ? t('teilweise signiert:', 'partly signed:')
160    : t('Signieren fehlgeschlagen:', 'Signing failed:')
161  const color = j.phase === 'signiert' ? 'green' : j.phase === 'gefunden' ? 'cyan' : 'yellow'
162  const actions: unknown[] = []
163  if (j.phase === 'gefunden' && !isBusy) actions.push(<Button key="sg-sign" label={t('Signieren', 'Sign')} plain onPress={() => void signAll($, scriptOption)} />)
164  if (j.phase !== 'gefunden' && !isBusy) {
165    actions.push(<Button key="sg-why" label="Details" plain onPress={() => $.ui.log(j.files.map(f => `${f.path}: ${f.message || (f.ok ? t('signiert', 'signed') : t('offen', 'open'))}`).join('\n'))} />)
166  }
167  actions.push(<Button key="sg-weg" label="×" plain onPress={() => void update($, job, x => (x ? { ...x, isDismissed: true } : x))} />)
168  return {
169    id: 'signGuard',
170    urgent: j.phase !== 'signiert',
171    short: (
172      <Text>
173        <Text color={color} bold>{'🔏'}</Text>
174        <Text dimColor>{` ${j.files.filter(f => f.ok).length}/${j.files.length}`}</Text>
175      </Text>
176    ),
177    line: (
178      <Text>
179        <Text color={color} bold>{'🔏 '}</Text>
180        <Text>{`${head} `}</Text>
181        <Text dimColor>{names}</Text>
182      </Text>
183    ),
184    actions,
185    status: `🔏 ${head} ${names}`,
186  }
187}
188
mods/savvy-progress/hooks/register.tsx 1083 lines
1// Modified for mod-box (https://github.com/LupusMalusDeviant/mod-box): type fix, state/matchers/tool names rewritten for the single plugin.
2// Original: https://github.com/JohnnyVizz/claude-kit/tree/main/plugins/savvy-progress (commit 272a877).
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register } from 'claude-code'
5
6import type { AgentRun, AgentStatus, Flow, Panel, Phase, PlannedTask } from '../types'
7
8const flow = atom({ plugin: 'mod-box', key: 'sp_flow' } as const, null)
9const agents = atom({ plugin: 'mod-box', key: 'sp_agents' } as const, [])
10const panel = atom({ plugin: 'mod-box', key: 'sp_panel' } as const, {
11  isCompact: false,
12  isDoneCollapsed: false,
13  autoOpenedFor: '',
14})
15const now = atom({ plugin: 'mod-box', key: 'sp_now' } as const, 0)
16
17const TOOL = 'mcp__mod-box__progress'
18const STEP_TOOL = 'mcp__mod-box__step'
19const PANE = 'savvy-agents'
20const PHASES: readonly Phase[] = ['plan', 'design', 'delegate', 'review', 'close']
21const ACCENT = '#8f8cf4'
22const DONE = '#5fbf8f'
23
24type ProgressInput = {
25  title?: string
26  total?: number
27  done?: number
28  phase?: Phase
29  finished?: boolean
30  tasks?: { title?: string; tier?: string; after?: number[] }[]
31}
32
33// ---------------------------------------------------------------------------
34// Language: the `language` option, else Claude Code's `language` setting, else the
35// process locale; English when nothing says Russian.
36
37type Lang = 'en' | 'ru'
38
39const STRINGS = {
40  en: {
41    pane: 'Agents',
42    cost: 'Cost',
43    tokens: 'Tokens',
44    time: 'Time',
45    collapse: 'Collapse',
46    expand: 'Expand',
47    running: 'Running',
48    finished: 'Finished',
49    planned: 'Planned',
50    empty: 'No subagents yet.',
51    round: 'round',
52    failed: 'error',
53    after: 'after',
54    tokensWord: 'tokens',
55    agentsCount: 'agents',
56    isRunning: 'running',
57    isFinished: 'finished',
58    isPlanned: 'planned',
59    opened: 'Agents panel opened.',
60    closed: 'Agents panel closed.',
61    done: 'Done',
62    plan: 'Plan',
63    design: 'Design',
64    tasks: 'Tasks',
65    review: 'Review',
66    busy: 'running',
67  },
68  ru: {
69    pane: 'Агенты',
70    cost: 'Стоимость',
71    tokens: 'Токены',
72    time: 'Время',
73    collapse: 'Свернуть',
74    expand: 'Развернуть',
75    running: 'Работают',
76    finished: 'Завершены',
77    planned: 'Запланированы',
78    empty: 'Субагентов пока нет.',
79    round: 'раунд',
80    failed: 'ошибка',
81    after: 'после',
82    tokensWord: 'токенов',
83    agentsCount: 'агентов',
84    isRunning: 'работает',
85    isFinished: 'завершён',
86    isPlanned: 'запланирована',
87    opened: 'Панель агентов открыта.',
88    closed: 'Панель агентов закрыта.',
89    done: 'Готово',
90    plan: 'План',
91    design: 'Дизайн',
92    tasks: 'Задачи',
93    review: 'Ревью',
94    busy: 'в работе',
95  },
96} as const
97
98// Module scope is fine here: session.start sets it again on every (re)load.
99let lang: Lang = 'en'
100const tr = () => STRINGS[lang]
101
102const isRussian = (v: unknown): boolean => typeof v === 'string' && /^(ru|russian|рус)/i.test(v.trim())
103
104async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
105  if (option === 'en' || option === 'ru') return option
106  try {
107    const settings = (await $.settings.read()) as Record<string, unknown>
108    if (typeof settings.language === 'string' && settings.language.trim()) return isRussian(settings.language) ? 'ru' : 'en'
109  } catch {
110    // No settings: fall through to the locale.
111  }
112  const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LC_MESSAGES')) || (await $.env.get('LANG'))
113  return isRussian(locale) ? 'ru' : 'en'
114}
115
116const blank = (): Flow => ({
117  title: 'savvy-flow',
118  total: 0,
119  done: 0,
120  running: 0,
121  phase: 'plan',
122  isFinished: false,
123  tasks: [],
124})
125
126const isNewFlow = (prev: Flow | null, input: ProgressInput): boolean =>
127  !prev || prev.isFinished || (input.title !== undefined && input.title.trim() !== prev.title)
128
129const cleanTasks = (tasks: ProgressInput['tasks']): PlannedTask[] | undefined =>
130  tasks
131    ?.filter(t => t.title?.trim())
132    .map(t => ({
133      title: (t.title ?? '').trim(),
134      tier: (t.tier ?? '').replace(/^savvy-/, '').trim().toLowerCase(),
135      after: (t.after ?? []).filter(n => Number.isInteger(n) && n > 0),
136    }))
137
138const merge = (prev: Flow | null, input: ProgressInput): Flow => {
139  // A new title means a new flow: never carry counters over from an earlier one.
140  const base = isNewFlow(prev, input) || !prev ? blank() : { ...blank(), ...prev }
141  const tasks = cleanTasks(input.tasks) ?? base.tasks
142  const total = Math.max(0, Math.round(input.total ?? (input.tasks ? tasks.length : base.total)))
143  const done = Math.min(total || Infinity, Math.max(0, Math.round(input.done ?? base.done)))
144  const phase = input.phase && PHASES.includes(input.phase) ? input.phase : base.phase
145  return {
146    ...base,
147    title: input.title?.trim() || base.title,
148    total,
149    done,
150    phase: input.finished ? 'close' : phase,
151    isFinished: input.finished === true,
152    tasks,
153  }
154}
155
156const label = (f: Flow): string => {
157  const s = tr()
158  if (f.isFinished) return s.done
159  if (f.phase === 'plan') return s.plan
160  if (f.phase === 'design') return s.design
161  const count = f.total ? `${f.done}/${f.total}` : `${f.running} ${s.busy}`
162  return `${f.phase === 'review' ? s.review : s.tasks} ${count}`
163}
164
165const ratio = (f: Flow): number => (f.isFinished ? 1 : f.total ? f.done / f.total : 0)
166
167// Deterministic noise so the dither does not shimmer between redraws.
168const noise = (x: number, y: number): number => {
169  const s = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
170  return s - Math.floor(s)
171}
172
173// The whole row is one SVG: the desktop wraps sibling elements onto new lines,
174// so title, bar, percent and the crab live in one drawing; only the count and
175// the dismiss are Buttons beside it.
176const H = 22
177const BAR_H = 16
178const CRAB_W = 26
179const CELL = 3
180const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','Segoe UI',sans-serif"
181
182const xml = (s: string): string =>
183  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
184
185const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
186
187// Rough advance of system UI text, in em; good enough to size the title's slot.
188const charEm = (ch: string): number =>
189  /[\s.,:;'|!il1()[\]]/.test(ch) ? 0.3 : /[A-ZА-ЯЁmwшщжюМШЩЖЮ@%]/.test(ch) ? 0.72 : 0.56
190
191const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
192
193// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
194const fitText = (s: string, size: number, maxW: number): string => {
195  if (textWidth(s, size) <= maxW) return s
196  let out = ''
197  for (const ch of s) {
198    if (textWidth(out + ch + '…', size) > maxW) break
199    out += ch
200  }
201  return out + '…'
202}
203
204const rowSvg = (f: Flow, W: number, isWorking: boolean): string => {
205  // The title takes what it needs, up to 40% of the row; the bar takes the rest.
206  const title = fitText(f.title, 13, Math.max(60, W * 0.4))
207  const BAR_X = Math.round(16 + textWidth(title, 13) + 12)
208  const BAR_W = Math.max(60, W - BAR_X - 46 - CRAB_W)
209  const color = f.isFinished ? DONE : ACCENT
210  const y0 = (H - BAR_H) / 2
211  const fillW = Math.round(BAR_W * ratio(f))
212  const runW = f.total ? Math.round((BAR_W * Math.min(f.total, f.done + f.running)) / f.total) : 0
213  const dots: string[] = []
214
215  // Dithered fill: sparse at the start, dense toward the head.
216  const cols = Math.floor(fillW / CELL)
217  const rows = Math.floor(BAR_H / CELL)
218  for (let c = 0; c < cols; c++) {
219    const density = 0.35 + 0.6 * Math.pow(c / Math.max(1, cols), 1.2)
220    for (let r = 0; r < rows; r++) {
221      if (noise(c, r) < density) dots.push(`<rect class="t${Math.floor(noise(r, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="2" height="2"/>`)
222    }
223  }
224  // Handed to workers, not yet accepted: a faint second layer.
225  const faint: string[] = []
226  for (let c = cols; c < Math.floor(runW / CELL); c++) {
227    for (let r = 0; r < rows; r++) {
228      if (noise(c + 7, r + 3) < 0.2) faint.push(`<rect class="t${Math.floor(noise(r + 5, c) * 4)}" x="${c * CELL + 1}" y="${r * CELL + 1}" width="1.7" height="1.7"/>`)
229    }
230  }
231
232  const ticks: string[] = []
233  for (let i = 1; i < f.total; i++) {
234    const x = Math.round((BAR_W * i) / f.total)
235    if (x > fillW + 4) ticks.push(`<rect x="${x}" y="${BAR_H / 2 - 4}" width="1.5" height="8" rx="0.75"/>`)
236  }
237
238  const text = label(f)
239  const pillW = Math.round(18 + text.length * 6.6)
240  const pillX = Math.max(0, Math.min(BAR_W - pillW, fillW - pillW))
241  const percent = `${Math.round(ratio(f) * 100)}%`
242
243  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
244<style>
245.t{fill:#1f1f1f}.m{fill:#8a8a8a}.k{fill:#e4e4e2}.tk{fill:#b4b4b0}
246@media (prefers-color-scheme: dark){.t{fill:#ececec}.m{fill:#9a9a9a}.k{fill:#2c2c2c}.tk{fill:#5a5a5a}}
247/* Pixels twinkle in four out-of-phase groups; a finished bar settles to a slow glow. */
248.t0,.t1,.t2,.t3{animation:tw ${f.isFinished ? 3.2 : 2.2}s ease-in-out infinite}
249.t1{animation-duration:${f.isFinished ? 3.8 : 2.8}s;animation-delay:-.7s}.t2{animation-duration:${f.isFinished ? 4.4 : 1.9}s;animation-delay:-1.3s}.t3{animation-duration:${f.isFinished ? 3.5 : 3.3}s;animation-delay:-.4s}
250@keyframes tw{0%,100%{opacity:1}50%{opacity:${f.isFinished ? 0.8 : 0.3}}}
251@media (prefers-reduced-motion: reduce){.t0,.t1,.t2,.t3{animation:none}}
252</style>
253<defs><clipPath id="c"><rect x="0" y="0" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/></clipPath></defs>
254<circle cx="5" cy="${H / 2}" r="4" fill="${color}"/>
255<text class="t" x="16" y="${H / 2 + 4.5}" font-family="${FONT}" font-size="13" font-weight="500">${xml(title)}</text>
256<g transform="translate(${BAR_X},${y0})">
257<rect class="k" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}"/>
258<g clip-path="url(#c)">
259<g fill="${color}">${dots.join('')}</g>
260<g fill="${color}" opacity="0.45">${faint.join('')}</g>
261<g class="tk">${ticks.join('')}</g>
262</g>
263<rect x="${pillX}" width="${pillW}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${color}"/>
264<text x="${pillX + pillW / 2}" y="${BAR_H / 2 + 4}" text-anchor="middle" font-family="${FONT}" font-size="11" font-weight="600" fill="#ffffff">${xml(text)}</text>
265</g>
266<text class="m" x="${W - CRAB_W - 6}" y="${H / 2 + 4.5}" text-anchor="end" font-family="${FONT}" font-size="12.5" font-variant-numeric="tabular-nums">${percent}</text>
267${CRAB_CSS}${crab(W - CRAB_W + 1, 0, 'other', false, isWorking, 0.8)}
268</svg>`
269}
270
271const barText = (f: Flow, width: number): string => {
272  const filled = Math.round(width * ratio(f))
273  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
274}
275
276// ---------------------------------------------------------------------------
277// Agents panel: every subagent of the session, plus the tasks the flow planned.
278
279const TIER_COLOR: Record<string, string> = {
280  fable: '#7F77DD',
281  heavy: '#D85A30',
282  careful: '#BA7517',
283  medium: '#378ADD',
284  light: '#1D9E75',
285  other: '#888780',
286}
287
288// What each savvy tier runs on, for planned tasks that have no run yet.
289const colorOf = (tier: string): string => TIER_COLOR[tier] ?? '#888780'
290
291const TIER_MODEL: Record<string, string> = {
292  fable: 'Fable · high',
293  heavy: 'Opus · xhigh',
294  careful: 'Opus · high',
295  medium: 'Opus · medium',
296  light: 'Opus · low',
297}
298
299// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
300// The engine reports tokens, not money, so the panel's cost is an estimate.
301const PRICES: [RegExp, [number, number, number, number]][] = [
302  [/fable|mythos/, [10, 50, 0.25, 12.5]],
303  [/opus-5-5/, [4, 20, 0.2, 5]],
304  [/opus/, [5, 25, 0.5, 6.25]],
305  [/sonnet/, [2, 10, 0.2, 2.5]],
306  [/haiku/, [1, 5, 0.1, 1.25]],
307]
308
309type Usage = {
310  input_tokens: number
311  output_tokens: number
312  cache_read_input_tokens: number
313  cache_creation_input_tokens: number
314}
315
316const priceOf = (model: string): [number, number, number, number] =>
317  PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
318
319const costOf = (model: string, u: Usage): number => {
320  const [i, o, r, w] = priceOf(model)
321  return (
322    ((u.input_tokens || 0) * i +
323      (u.output_tokens || 0) * o +
324      (u.cache_read_input_tokens || 0) * r +
325      (u.cache_creation_input_tokens || 0) * w) /
326    1e6
327  )
328}
329
330const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
331
332// `savvy-careful`, or `savvy-flow:savvy-careful` when the agents ship in a plugin.
333const tierOf = (type: string): string => {
334  const bare = type.replace(/^[^:]*:/, '')
335  const t = bare.replace(/^savvy-/, '').toLowerCase()
336  return t in TIER_COLOR && bare.startsWith('savvy-') ? t : 'other'
337}
338
339const modelName = (id: string): string => {
340  const m = /(fable|mythos|opus|sonnet|haiku)-(\d+)(?:-(\d{1,2})(?!\d))?/i.exec(id)
341  const [, family = '', major = '', minor] = m ?? []
342  if (!family) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
343  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()} ${major}${minor ? '.' + minor : ''}`
344}
345
346const norm = (s: string): string => s.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
347
348const fmtTokens = (n: number): string =>
349  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${Math.round(n)}`
350
351const fmtCost = (usd: number): string => `$${usd < 10 ? usd.toFixed(2) : usd.toFixed(1)}`
352
353const fmtTime = (ms: number): string => {
354  const s = Math.max(0, Math.round(ms / 1000))
355  const h = Math.floor(s / 3600)
356  const m = Math.floor((s % 3600) / 60)
357  const ss = String(s % 60).padStart(2, '0')
358  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
359}
360
361const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
362
363type Planned = PlannedTask & { n: number }
364
365const plannedOf = (f: Flow | null, list: AgentRun[]): Planned[] => {
366  if (!f || f.isFinished) return []
367  const started = new Set(list.map(a => norm(a.description)))
368  return (f.tasks ?? []).map((t, i) => ({ ...t, n: i + 1 })).filter(t => !started.has(norm(t.title)))
369}
370
371const totals = (list: AgentRun[], at: number) => {
372  const cost = list.reduce((s, a) => s + a.costUsd, 0)
373  const tokens = list.reduce((s, a) => s + a.tokens, 0)
374  const start = Math.min(...list.map(a => a.startedAt))
375  const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
376  return { cost, tokens, time: list.length ? end - start : 0 }
377}
378
379// --- desktop drawings: each row is one SVG, as the band above the prompt is.
380
381const PANE_CSS = `<style>
382.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
383@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
384.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
385@media (prefers-reduced-motion: reduce){.live{animation:none}}
386</style>`
387
388// Pixel Clawd from DockCrab (Clawdy): a 24×18 crab on a 30×28 grid, one costume per tier.
389// The body keeps the brand clay; the tier's color lives in the costume's accent.
390const CLAY = '#D97757'
391const INK = '#1F1E1D'
392
393// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
394// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
395type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
396
397const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
398  rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
399
400// `armCls` lets a raised claw travel with the prop it holds.
401const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
402  f(7, 10, 16, 12, CLAY)
403  f(3, 14, 4, 4, CLAY)
404  f(23, 14 + armFront, 4, 4, CLAY, armCls)
405  f(9, 12, 2, 2, INK)
406  f(19, 12, 2, 2, INK)
407  f(7, 22, 2, 4, CLAY, 'la')
408  f(17, 22, 2, 4, CLAY, 'la')
409  f(11, 22, 2, 4, CLAY, 'lb')
410  f(21, 22, 2, 4, CLAY, 'lb')
411}
412
413// Pure CSS, run by the compositor: no redraws. Periods divide one second, so the
414// once-a-second redraw of a running row restarts them in phase. Every crab walks;
415// each costume adds its prop's own motion on top.
416const CRAB_CSS = `<style>
417.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
418.run .bd{animation:bob .5s steps(1) infinite -.125s}
419.run g{transform-box:fill-box}
420@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
421.c-fable.run{animation:float 1s ease-in-out infinite}
422.c-fable.run .la,.c-fable.run .lb,.c-fable.run .bd{animation:none}
423.c-fable.run .ant{animation:blink 1s steps(1) infinite}
424.c-fable.run .star{animation:blink .5s steps(1) infinite -.25s}
425@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
426.c-heavy.run .it{animation:scan 1s steps(1) infinite}
427.c-heavy.run .gl{animation:blink 1s steps(1) infinite -.5s}
428@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
429.c-careful.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
430@keyframes twist{50%{transform:rotate(-35deg)}}
431.c-medium.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
432.c-medium.run .egg{animation:flip 1s ease-in-out infinite}
433@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
434.c-light.run .la{animation-duration:.25s}.c-light.run .lb{animation-duration:.25s;animation-delay:-.125s}
435.c-light.run .flag{transform-origin:0 50%;animation:wave .25s steps(1) infinite}
436@keyframes wave{50%{transform:skewY(-12deg) scaleX(.85)}}
437.c-explore.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
438@keyframes fence{50%{transform:rotate(25deg)}}
439@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
440</style>`
441
442const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
443  // Fable: astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
444  fable: (f, t) => {
445    crabBody(f)
446    f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
447    f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
448    f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
449    f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
450  },
451  // Heavy: detective with a deerstalker; the magnifier sweeps and glints.
452  heavy: (f, t) => {
453    crabBody(f, -4, 'it')
454    stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
455    f(6, 9, 18, 1, t)
456    stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
457    f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
458  },
459  // Careful: engineer in a hard hat; the wrench turns a bolt.
460  careful: (f, t) => {
461    crabBody(f)
462    stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
463    f(13, 5, 4, 2, t)
464    stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
465  },
466  // Medium: chef, the toque traced from DockCrab's Sprites.chefHat; tosses the omelette.
467  medium: (f, t) => {
468    crabBody(f, -4, 'pan')
469    stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
470    f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
471  },
472  // Light: racer in a helmet; runs at double pace, the checkered flag flutters.
473  light: (f, t) => {
474    crabBody(f, -4)
475    stamp(f, 6, 5, ['....rrrrrrrrrr....', '..rrrrrrwwrrrrrr..', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.rrrrrrrwwrrrrrrr.', '.kkkkkkkkkkkkkkkkr'], { r: t, w: '#F8F6F1', k: INK })
476    f(25, 1, 1, 9, '#8E929A')
477    stamp(f, 26, 1, ['wkwk', 'kwkw', 'wkwk'], { w: '#F8F6F1', k: INK }, 'flag')
478  },
479  // Explore: pirate scouting the code; the cutlass fences.
480  explore: f => {
481    crabBody(f)
482    stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
483    f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
484    f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
485  },
486  other: f => crabBody(f),
487}
488
489const costumeOf = (type: string): string => (type === 'Explore' ? 'explore' : tierOf(type))
490
491const CRAB_SCALE = 1.1
492
493// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
494// legs stay outside it and step on their own.
495const crab = (x: number, y: number, costume: string, dim = false, isWalking = false, scale = CRAB_SCALE): string => {
496  const groups = new Map<string, string[]>([['bd', []]])
497  const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
498    if (!groups.has(cls)) groups.set(cls, [])
499    groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
500  }
501  const draw = COSTUMES[costume] ?? ((g: Fill) => crabBody(g))
502  draw(f, colorOf(costume))
503  const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
504  const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
505  const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
506  return `<g transform="translate(${x},${y}) scale(${scale})" opacity="${dim ? 0.45 : 1}" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
507}
508
509const statusMark = (x: number, y: number, status: string, color: string): string => {
510  if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
511  if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
512  if (status === 'failed') return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
513  return `<circle cx="${x}" cy="${y}" r="5" fill="none" stroke="#9a9a96" stroke-width="1.4"/><path d="M${x} ${y - 2.5}v2.8l1.8 1.2" fill="none" stroke="#9a9a96" stroke-width="1.4" stroke-linecap="round"/>`
514}
515
516const svg = (W: number, H: number, body: string): string =>
517  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
518
519// The pane's own title already says "Agents": the header names the flow, if any.
520const headerSvg = (W: number, title: string, t: ReturnType<typeof totals>): string => {
521  const s = tr()
522  const gap = 6
523  const tw = (W - gap * 2) / 3
524  const top = title ? 28 : 0
525  const tile = (i: number, k: string, v: string) =>
526    `<rect class="tile" x="${i * (tw + gap)}" y="${top}" width="${tw}" height="40" rx="8"/>
527<text class="s" x="${i * (tw + gap) + 9}" y="${top + 16}" font-family="${FONT}" font-size="11">${k}</text>
528<text class="t" x="${i * (tw + gap) + 9}" y="${top + 33}" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
529  return svg(
530    W,
531    headerHeight(title),
532    `${title ? `<text class="t" x="0" y="15" font-family="${FONT}" font-size="14" font-weight="600">${xml(fitText(title, 14, W))}</text>` : ''}
533${tile(0, s.cost, '≈' + fmtCost(t.cost))}${tile(1, s.tokens, fmtTokens(t.tokens))}${tile(2, s.time, fmtTime(t.time))}`,
534  )
535}
536
537const headerHeight = (title: string): number => (title ? 72 : 44)
538
539// The task's own progress when the worker reports steps; a finished run is full.
540const progressOf = (a: AgentRun): number | null => {
541  if (a.status === 'done') return 1
542  if (a.stepTotal) return Math.min(1, (a.stepDone ?? 0) / a.stepTotal)
543  return null
544}
545
546const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
547
548const agentSvg = (W: number, a: AgentRun, at: number): string => {
549  const s = tr()
550  const tier = tierOf(a.type)
551  const color = colorOf(tier)
552  const ctx = ctxOf(a)
553  const textW = W - 42 - 22
554  const meta = [a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)]
555  if (a.round > 1) meta.push(`${s.round} ${a.round}`)
556  if (a.status === 'failed') meta.push(s.failed)
557  const barW = textW
558  const progress = progressOf(a)
559  const stats = `ctx ${ctx}% · ${fmtTokens(a.contextTokens)}  ≈${fmtCost(a.costUsd)}  ${fmtTime(elapsed(a, at))}`
560  const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' · ' + a.stepNote : ''}` : ''
561  const stepsW = Math.max(0, barW - textWidth(stats, 11) - 12)
562  // Without reported steps the bar falls back to the context, drawn grey.
563  const fillW = Math.round(barW * (progress ?? ctx / 100))
564  return svg(
565    W,
566    66,
567    `${crab(0, 14, costumeOf(a.type), false, a.status === 'running')}
568<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
569<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier === 'other' ? a.type : tier)}</tspan><tspan class="s">  ${xml(meta.join('  ·  '))}</tspan></text>
570${steps && stepsW > 30 ? `<text class="t" x="42" y="49" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(fitText(steps, 11, stepsW))}</text>` : ''}
571<text class="s" x="${42 + barW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${stats}</text>
572<rect class="k" x="42" y="55" width="${barW}" height="4" rx="2"/><rect${progress === null ? ' class="m"' : ''} x="42" y="55" width="${fillW}" height="4" rx="2"${progress === null ? '' : ` fill="${color}"`}/>
573${statusMark(W - 8, 16, a.status, color)}
574<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
575  )
576}
577
578const plannedSvg = (W: number, p: Planned): string => {
579  const tier = p.tier in TIER_COLOR ? p.tier : 'other'
580  const color = colorOf(tier)
581  const textW = W - 42 - 22
582  const meta = [TIER_MODEL[tier] ?? '']
583  if (p.after.length) meta.push(`${tr().after} ${p.after.join(', ')}`)
584  return svg(
585    W,
586    46,
587    `${crab(0, 6, tier, true)}
588<text class="s" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(`${p.n}. ${p.title}`, 13, textW))}</text>
589<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(tier)}</tspan><tspan class="m">  ${xml(meta.filter(Boolean).join('  ·  '))}</tspan></text>
590${statusMark(W - 8, 16, 'planned', color)}
591<line class="ln" x1="0" y1="45.5" x2="${W}" y2="45.5"/>`,
592  )
593}
594
595const compactSvg = (W: number, list: AgentRun[], planned: Planned[], t: ReturnType<typeof totals>): string => {
596  const icons = [
597    ...list.filter(a => a.status === 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: 'running', dim: false })),
598    ...list.filter(a => a.status !== 'running').map(a => ({ k: costumeOf(a.type), c: colorOf(tierOf(a.type)), s: a.status, dim: false })),
599    ...planned.map(p => ({ k: p.tier in TIER_COLOR ? p.tier : 'other', c: colorOf(p.tier), s: 'planned', dim: true })),
600  ]
601  const fit = Math.max(1, Math.floor((W - 150) / 36))
602  const shown = icons.slice(0, fit)
603  const more = icons.length - shown.length
604  const body = shown
605    .map((ic, i) => crab(i * 36, 0, ic.k, ic.dim, ic.s === 'running') + (ic.s === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${ic.c}"/>` : ''))
606    .join('')
607  const x = shown.length * 36 + (more ? 4 : 0)
608  return svg(
609    W,
610    32,
611    `${body}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
612<text class="s" x="${W}" y="21" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">≈${fmtCost(t.cost)} · ${fmtTokens(t.tokens)} · ${fmtTime(t.time)}</text>`,
613  )
614}
615
616// --- terminal drawing: the same rows in text.
617
618const ctxBar = (pct: number, width: number): string => {
619  const filled = Math.round((width * pct) / 100)
620  return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
621}
622
623const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗', planned: '◷' }
624
625// Opens the agents pane, or closes it when it is up; true when it ends up open.
626async function togglePane($: EngineInterface): Promise<boolean> {
627  const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
628  if (isOpen) {
629    await $.ui.close({ id: PANE })
630    return false
631  }
632  const at = await $.clock.now()
633  await update($, now, () => at)
634  await $.ui.open({ id: PANE, title: tr().pane })
635  return true
636}
637
638async function autoOpen($: EngineInterface, key: string): Promise<void> {
639  const p = await read($, panel)
640  if (p.autoOpenedFor === key) return
641  await update($, panel, prev => ({ ...prev, autoOpenedFor: key }))
642  void $.ui.open({ id: PANE, title: tr().pane })
643}
644
645export const register: Register = (on, options) => {
646  on('session.start', {}, async ($, e, next) => {
647    const started = await next(e)
648    lang = await detectLang($, options.language)
649    await $.tool.register({
650      name: 'progress',
651      description:
652        'Report /savvy-flow progress to the progress bar above the prompt and the agents panel. ' +
653        'Call it after presenting the plan (title, total, tasks, phase "delegate"), each time a task is accepted (done), ' +
654        'when switching phase or re-planning (tasks), and once at the end with finished: true. Fields left out keep their previous value.',
655      inputSchema: {
656        type: 'object',
657        properties: {
658          title: { type: 'string', description: 'Short name of the overall task, a few words.' },
659          total: { type: 'integer', minimum: 0, description: 'Number of planned worker tasks.' },
660          done: { type: 'integer', minimum: 0, description: 'Number of tasks accepted after review.' },
661          phase: { type: 'string', enum: [...PHASES] },
662          finished: { type: 'boolean', description: 'True once the flow is closed.' },
663          tasks: {
664            type: 'array',
665            description:
666              'The planned worker tasks in order, numbered from 1. Each title must equal the Agent tool `description` the task will be delegated with, so the panel can match runs to tasks.',
667            items: {
668              type: 'object',
669              properties: {
670                title: { type: 'string', description: 'A few words; reused verbatim as the Agent description.' },
671                tier: { type: 'string', enum: ['fable', 'heavy', 'careful', 'medium', 'light'] },
672                after: { type: 'array', items: { type: 'integer' }, description: 'Numbers of the tasks this one waits for.' },
673              },
674              required: ['title', 'tier'],
675            },
676          },
677        },
678      },
679    })
680    await $.tool.register({
681      name: 'step',
682      description:
683        'For savvy-flow workers: report progress on your own task to the agents panel. ' +
684        'Right after reading the brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
685        'call it again as each step finishes. Cheap and silent: it only draws a bar.',
686      inputSchema: {
687        type: 'object',
688        properties: {
689          done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
690          total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
691          note: { type: 'string', description: 'The step in progress, a few words.' },
692        },
693        required: ['done'],
694      },
695    })
696    await $.command.register({
697      name: 'agents-info',
698      description: 'Show or hide the panel of subagents: running, finished and planned, with model, context, cost and time',
699    })
700
701    // Ticks the running agents' clocks; quiet when nothing runs.
702    $.clock.every(1000, () => {
703      void (async () => {
704        const list = await read($, agents)
705        if (!list.some(a => a.status === 'running')) return
706        const at = await $.clock.now()
707        await update($, now, () => at)
708      })()
709    })
710    return started
711  })
712
713  on('command.run', { command: 'agents-info' }, async $ => {
714    const isOpen = await togglePane($)
715    return { text: isOpen ? tr().opened : tr().closed }
716  })
717
718  on('tool.call', { tool: TOOL }, async ($, e) => {
719    const input = e as unknown as ProgressInput
720    const prev = await read($, flow)
721    if (isNewFlow(prev, input) && input.title !== undefined) {
722      // A new flow starts with a clean list; agents still running stay.
723      await update($, agents, list => list.filter(a => a.status === 'running'))
724    }
725    const next = await update($, flow, p => merge(p, input))
726    if (next && input.tasks?.length) await autoOpen($, next.title)
727    return { result: `ok: ${label(next ?? blank())}` }
728  })
729
730  // A worker's own progress: the call runs in the worker's loop, so agentId names it.
731  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
732    const input = e as unknown as { done?: number; total?: number; note?: string }
733    const agentId = e.agentId
734    if (!agentId) return { result: 'ignored: only subagents report steps' }
735    await update($, agents, list =>
736      list.map(a => {
737        if (a.agentId !== agentId) return a
738        const total = Math.max(0, Math.round(input.total ?? a.stepTotal ?? 0))
739        const done = Math.max(0, Math.round(input.done ?? a.stepDone ?? 0))
740        return { ...a, stepTotal: total, stepDone: total ? Math.min(total, done) : done, stepNote: input.note?.trim() || undefined }
741      }),
742    )
743    return { result: 'ok' }
744  })
745
746  // Safety net: worker launches move the faint layer even if the orchestrator forgets to report.
747  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
748    const type = String(e.subagent_type ?? '')
749    if (!type.startsWith('savvy-')) return next(e)
750
751    await update($, flow, prev => {
752      const base = prev && !prev.isFinished ? { ...blank(), ...prev } : blank()
753      return { ...base, running: base.running + 1, phase: base.phase === 'plan' ? 'delegate' : base.phase }
754    })
755    try {
756      return await next(e)
757    } finally {
758      await update($, flow, prev => (prev ? { ...prev, running: Math.max(0, prev.running - 1) } : prev))
759    }
760  })
761
762  on('agent.spawn', {}, async ($, e, next) => {
763    const started = await next(e)
764    if (started.deny !== undefined) return started
765
766    const at = await $.clock.now()
767    await update($, agents, list => {
768      const round = 1 + list.filter(a => norm(a.description) === norm(e.description) && e.description).length
769      const run: AgentRun = {
770        id: started.agentId ?? e.tool_use_id,
771        agentId: started.agentId,
772        type: e.subagentType,
773        description: e.description,
774        model: started.model,
775        status: 'running',
776        startedAt: at,
777        contextTokens: 0,
778        contextMax: windowOf(started.model),
779        tokens: 0,
780        costUsd: 0,
781        steps: 0,
782        round,
783      }
784      return [...list.filter(a => a.id !== run.id), run].slice(-200)
785    })
786    await update($, now, () => at)
787    if (e.subagentType.startsWith('savvy-')) {
788      const f = await read($, flow)
789      await autoOpen($, f && !f.isFinished ? f.title : 'savvy-flow')
790    }
791    return started
792  })
793
794  // Each model request of a subagent: live context, tokens and cost.
795  on('turn.step', {}, async function* ($, e, next) {
796    const result = yield* next(e)
797    const agentId = e.agentId
798    const usage = result.usage
799    if (!agentId || !usage) return result
800
801    const model = usage.model || e.model
802    await update($, agents, list =>
803      list.map(a =>
804        a.agentId !== agentId
805          ? a
806          : {
807              ...a,
808              model,
809              effort: typeof e.effort === 'string' ? e.effort : a.effort,
810              status: 'running' as const,
811              endedAt: undefined,
812              contextTokens:
813                (usage.input_tokens || 0) +
814                (usage.cache_read_input_tokens || 0) +
815                (usage.cache_creation_input_tokens || 0) +
816                (usage.output_tokens || 0),
817              contextMax: windowOf(model),
818              tokens:
819                a.tokens +
820                (usage.input_tokens || 0) +
821                (usage.output_tokens || 0) +
822                (usage.cache_read_input_tokens || 0) +
823                (usage.cache_creation_input_tokens || 0),
824              costUsd: a.costUsd + costOf(model, usage),
825              steps: a.steps + 1,
826            },
827      ),
828    )
829    return result
830  })
831
832  on('turn.complete', {}, async ($, e, next) => {
833    const agentId = e.agentId
834    if (agentId) {
835      const at = await $.clock.now()
836      await update($, agents, list =>
837        list.map(a => {
838          if (a.agentId !== agentId) return a
839          // A run whose steps went unseen still gets the turn's own sum.
840          const fallback = a.steps === 0 && e.usage
841          return {
842            ...a,
843            status: (e.reason === 'answer' ? 'done' : 'failed') as AgentStatus,
844            endedAt: at,
845            ...(fallback && e.usage
846              ? {
847                  model: e.usage.model || a.model,
848                  tokens:
849                    e.usage.input_tokens +
850                    e.usage.output_tokens +
851                    e.usage.cache_read_input_tokens +
852                    e.usage.cache_creation_input_tokens,
853                  costUsd: costOf(e.usage.model || a.model, e.usage),
854                }
855              : {}),
856          }
857        }),
858      )
859      await update($, now, () => at)
860    }
861    return next(e)
862  })
863
864  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
865    const s = tr()
866    const ui = $.ui.resolve(e)
867    const { Box, Text, Button } = ui
868    const list = await read($, agents)
869    const f = await read($, flow)
870    const p: Panel = await read($, panel)
871    const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
872
873    const running = list.filter(a => a.status === 'running').reverse()
874    const finished = list.filter(a => a.status !== 'running').reverse()
875    const planned = plannedOf(f, list)
876    const t = totals(list, at)
877    // The pane's title says "Agents"; inside, only the flow's own name.
878    const title = f && !f.isFinished ? f.title : ''
879
880    const toggleCompact = (
881      <Button
882        key="compact"
883        label={p.isCompact ? s.expand : s.collapse}
884        plain
885        onPress={() => update($, panel, prev => ({ ...prev, isCompact: !prev.isCompact }))}
886      />
887    )
888    const toggleDone = (
889      <Button
890        key="done"
891        label={`${p.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
892        plain
893        onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
894      />
895    )
896    const isEmpty = list.length === 0 && planned.length === 0
897    const summary = `≈${fmtCost(t.cost)}, ${fmtTokens(t.tokens)} ${s.tokensWord}, ${fmtTime(t.time)}`
898
899    if (e.surface === 'desktop' && 'Svg' in ui) {
900      const { Svg } = ui
901      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
902      const section = (key: string, text: string) => (
903        <Text key={key} dimColor>
904          {text}
905        </Text>
906      )
907
908      if (p.isCompact) {
909        return (
910          <Box flexDirection="column" gap={1}>
911            <Svg source={compactSvg(W, list, planned, t)} alt={`${list.length} ${s.agentsCount}, ${summary}`} width={W} height={32} />
912            {toggleCompact}
913          </Box>
914        )
915      }
916      return (
917        <Box flexDirection="column">
918          <Svg source={headerSvg(W, title, t)} alt={title ? `${title}: ${summary}` : summary} width={W} height={headerHeight(title)} />
919          {toggleCompact}
920          {isEmpty && <Text dimColor>{s.empty}</Text>}
921          {running.length > 0 && section('h-run', `${s.running} · ${running.length}`)}
922          {running.map(a => (
923            <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isRunning}`} width={W} height={66} />
924          ))}
925          {finished.length > 0 && toggleDone}
926          {!p.isDoneCollapsed &&
927            finished.map(a => (
928              <Svg key={a.id} source={agentSvg(W, a, at)} alt={`${a.description}: ${modelName(a.model)}, ${s.isFinished}`} width={W} height={66} />
929            ))}
930          {planned.length > 0 && section('h-plan', `${s.planned} · ${planned.length}`)}
931          {planned.map(pl => (
932            <Svg key={`plan-${pl.n}`} source={plannedSvg(W, pl)} alt={`${pl.n}. ${pl.title}: ${s.isPlanned}`} width={W} height={46} />
933          ))}
934        </Box>
935      )
936    }
937
938    // Terminal: the same content in text rows.
939    const cols = Math.max(24, e.props.bodyColumns || 40)
940    const barW = Math.max(6, Math.min(20, cols - 34))
941    const row = (a: AgentRun) => {
942      const tier = tierOf(a.type)
943      const color = colorOf(tier)
944      const ctx = ctxOf(a)
945      const progress = progressOf(a)
946      const model = a.effort ? `${modelName(a.model)} · ${a.effort}` : modelName(a.model)
947      const steps = a.stepTotal ? `${a.stepDone ?? 0}/${a.stepTotal}${a.stepNote ? ' ' + a.stepNote : ''} · ` : ''
948      return (
949        <Box key={a.id} flexDirection="column" marginBottom={1}>
950          <Box flexDirection="row" gap={1}>
951            <Text color={color}>▣</Text>
952            <Text bold wrap="truncate-end">
953              {a.description || a.type}
954            </Text>
955            <Text color={a.status === 'failed' ? 'red' : a.status === 'done' ? 'green' : color}>{STATUS_GLYPH[a.status]}</Text>
956          </Box>
957          <Text dimColor wrap="truncate-end">
958            {'  '}
959            {tier === 'other' ? a.type : tier} · {model}
960            {a.round > 1 ? ` · ${s.round} ${a.round}` : ''}
961          </Text>
962          <Text wrap="truncate-end">
963            {'  '}
964            {progress === null ? <Text dimColor>{ctxBar(ctx, barW)}</Text> : <Text color={color}>{ctxBar(progress * 100, barW)}</Text>}
965            <Text dimColor>
966              {' '}
967              {steps}ctx {ctx}% · {fmtTokens(a.contextTokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}
968            </Text>
969          </Text>
970        </Box>
971      )
972    }
973
974    return (
975      <Box flexDirection="column">
976        <Box flexDirection="row" justifyContent="space-between">
977          <Text bold wrap="truncate-end">
978            {title}
979          </Text>
980          {toggleCompact}
981        </Box>
982        <Text dimColor>
983          ≈{fmtCost(t.cost)} · {fmtTokens(t.tokens)} {s.tokensWord} · {fmtTime(t.time)}
984        </Text>
985        {p.isCompact ? (
986          <Text wrap="truncate-end">
987            {[...running, ...finished].map(a => (
988              <Text key={a.id} color={colorOf(tierOf(a.type))}>
989                {STATUS_GLYPH[a.status]}{' '}
990              </Text>
991            ))}
992            {planned.map(pl => (
993              <Text key={`plan-${pl.n}`} dimColor>
994                ◷{' '}
995              </Text>
996            ))}
997          </Text>
998        ) : (
999          <Box flexDirection="column" marginTop={1}>
1000            {isEmpty && <Text dimColor>{s.empty}</Text>}
1001            {running.length > 0 && <Text dimColor>{s.running} · {running.length}</Text>}
1002            {running.map(row)}
1003            {finished.length > 0 && toggleDone}
1004            {!p.isDoneCollapsed && finished.map(row)}
1005            {planned.length > 0 && <Text dimColor>{s.planned} · {planned.length}</Text>}
1006            {planned.map(pl => {
1007              const tier = pl.tier in TIER_COLOR ? pl.tier : 'other'
1008              return (
1009                <Box key={`plan-${pl.n}`} flexDirection="column" marginBottom={1}>
1010                  <Text dimColor wrap="truncate-end">
1011                    <Text color={colorOf(tier)}>▢</Text> {pl.n}. {pl.title} ◷
1012                  </Text>
1013                  <Text dimColor wrap="truncate-end">
1014                    {'  '}
1015                    {tier} · {TIER_MODEL[tier] ?? ''}
1016                    {pl.after.length ? ` · ${s.after} ${pl.after.join(', ')}` : ''}
1017                  </Text>
1018                </Box>
1019              )
1020            })}
1021          </Box>
1022        )}
1023      </Box>
1024    )
1025  })
1026
1027  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1028    const f = await read($, flow)
1029    if (f === null || e.props.hasSurvey) return next(e)
1030
1031    const ui = $.ui.resolve(e)
1032    const { Box, Text, Button } = ui
1033    const list = await read($, agents)
1034    const crew = list.length + plannedOf(f, list).length
1035    const isWorking = list.some(a => a.status === 'running')
1036    const crewButton = (
1037      <Button key="savvy-agents" label={`×${crew}`} plain onPress={() => void togglePane($)} />
1038    )
1039    const percent = `${Math.round(ratio(f) * 100)}%`
1040    const dismiss = (
1041      <Button
1042        key="savvy-dismiss"
1043        label="✕"
1044        plain
1045        role="dismiss"
1046        onPress={() => update($, flow, () => null)}
1047      />
1048    )
1049
1050    if ('Svg' in ui) {
1051      const { Svg } = ui
1052      // About 8 CSS px per reported column; the rest is the count, the dismiss
1053      // and their gaps. No floor above the slot: a row wider than it would wrap.
1054      const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
1055      return (
1056        <Box flexDirection="row" alignItems="center" gap={1}>
1057          <Svg source={rowSvg(f, width, isWorking)} alt={`${f.title}: ${label(f)}, ${percent}`} width={width} height={H} />
1058          {crewButton}
1059          {dismiss}
1060        </Box>
1061      )
1062    }
1063
1064    const cols = e.props.bodyColumns
1065    const titleW = Math.max(8, Math.min(30, f.title.length + 2, Math.floor(cols / 3)))
1066    const width = Math.max(6, Math.min(40, cols - titleW - 32))
1067    return (
1068      <Box flexDirection="row" gap={2}>
1069        <Box width={titleW} flexShrink={0}>
1070          <Text color={f.isFinished ? DONE : ACCENT}>● </Text>
1071          <Text wrap="truncate-end">{f.title}</Text>
1072        </Box>
1073        <Text color={f.isFinished ? DONE : ACCENT}>{barText(f, width)}</Text>
1074        <Text bold>{label(f)}</Text>
1075        <Text dimColor>{percent}</Text>
1076        <Text color={CLAY}>▣</Text>
1077        {crewButton}
1078        {dismiss}
1079      </Box>
1080    )
1081  })
1082}
1083
mods/filetree/hooks/register.tsx 1854 lines
1// Modified for mod-box (https://github.com/LupusMalusDeviant/mod-box): state, matchers and paths rewritten for the single plugin; a Features tab for the Codebase Map; on the desktop the rows and the preview stay under the engine's 100,000-character limit for a Client's props.
2// Original: https://github.com/data-goblin/claude-code-filetree (commit bf8de32).
3import { type BuiltinToolResults, type EngineInterface, type Register, type Timer, update } from 'claude-code'
4import { LINUX_LAUNCH, openCommand, openWithCommand } from './open'
5
6import type { Activity, FileNode, FileTree, FileView, Theme } from '../types'
7import { BRANCH_ICON, chainOf, type GitAction, gitActions, readOnly, readTargets, resolve, TONES } from './git'
8import type { RowSpec, RowsProps, Seg } from './rows'
9import { CHEVRON_CLOSED, CHEVRON_OPEN, fileIcon, GIT_COLOR } from './icons'
10import { APPS, type AppKind, detectCommand, kindsFor, launchCommand, readDetected, readExtraApps, revealCommand } from './apps'
11import { TEXT as MAP_TEXT } from '../../codebase-map/hooks/i18n'
12import {
13  ancestorsOf,
14  type Change,
15  DEFAULT_THEME,
16  dirname,
17  emptyTree,
18  formatSize,
19  inside,
20  isAbsolute,
21  join,
22  mapper,
23  parseGit,
24  parseNumstat,
25  parseTheme,
26  posix,
27  relative,
28  dropBelow,
29  useDrives,
30  rollCounts,
31  replaceChildren,
32  rollUp,
33  stamp,
34  toNodes,
35  underAny,
36  visibleRows,
37} from './tree'
38
39const TREE = { plugin: 'mod-box', key: 'ft_tree' } as const
40const THEME = { plugin: 'mod-box', key: 'ft_theme' } as const
41const VIEW = { plugin: 'mod-box', key: 'ft_view' } as const
42const MENU = { plugin: 'mod-box', key: 'ft_menu' } as const
43const APPS_FOUND = { plugin: 'mod-box', key: 'ft_apps' } as const
44let extraApps: { label: string; command: string }[] = []
45let detecting: Promise<void> | null = null
46
47// Mod Box: "Open with". The programs are looked for once per session, at the first menu.
48function findApps($: EngineInterface): Promise<void> {
49  detecting ??= (async () => {
50    const os = await osName($)
51    const { argv, env } = detectCommand(os)
52    let found: Record<string, string> = {}
53    try {
54      const run = await $.process.run(argv, { timeoutMs: 20_000, ...(env ? { env } : {}) })
55      found = readDetected(os, run.stdout)
56    } catch {
57      found = {}
58    }
59    await $.state.set(APPS_FOUND, found)
60  })()
61  return detecting
62}
63
64async function openMenu($: EngineInterface, id: string): Promise<void> {
65  await $.state.set(MENU, id)
66  void findApps($)
67}
68
69async function closeMenu($: EngineInterface): Promise<void> {
70  await $.state.set(MENU, '')
71}
72
73async function runOpen($: EngineInterface, what: string, cmd: { argv: string[]; env?: Record<string, string> }): Promise<void> {
74  try {
75    const run = await $.process.run(cmd.argv, { timeoutMs: 15_000, ...(cmd.env ? { env: cmd.env } : {}) })
76    if (run.exitCode !== 0) $.ui.toast(`${tx('Konnte nicht öffnen', 'Could not open')} (${what}): ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
77  } catch {
78    $.ui.toast(`${tx('Konnte nicht öffnen', 'Could not open')} (${what})`)
79  }
80}
81
82async function openWithApp($: EngineInterface, appId: string, path: string): Promise<void> {
83  const target = ((await $.state.get(APPS_FOUND)).value ?? {})[appId]
84  const app = APPS.find(a => a.id === appId)
85  await closeMenu($)
86  if (!target || !app) return
87  await runOpen($, app.label, launchCommand(await osName($), target, path, LINUX_LAUNCH))
88}
89
90async function openWithExtra($: EngineInterface, index: number, path: string): Promise<void> {
91  const extra = extraApps[index]
92  await closeMenu($)
93  if (!extra) return
94  const os = await osName($)
95  const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
96  const argv = openWithCommand(extra.command, native(path), native(dirname(path)))
97  if (!argv) return
98  await runOpen($, extra.label, { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...argv] : argv })
99}
100
101// Mod Box: the Features tab reads the Codebase Map's state (mod codebase-map, prefix cm2). Its
102// shapes are checked here instead of imported, so the file tree also works without that mod.
103type MapFeat = { slug: string; name: string; summary: string; files: string[]; deps: string[] }
104type MapEdit = { slug: string | null; file: string; agentId: string | null }
105type MapStatus = { kind: 'fresh' | 'behind' | 'unknown-stamp' | 'no-stamp'; commits?: number; files?: number }
106// Literal references, so the engine can list what this module reads.
107const CM_MAP = { plugin: 'mod-box', key: 'cm2_map' } as const
108const CM_STATUS = { plugin: 'mod-box', key: 'cm2_status' } as const
109const CM_ACTIVE = { plugin: 'mod-box', key: 'cm2_active' } as const
110const CM_TOUCHED = { plugin: 'mod-box', key: 'cm2_touched' } as const
111const CM_LOOSE = { plugin: 'mod-box', key: 'cm2_unassigned' } as const
112// A value read from the Codebase Map's state, if it has the expected shape.
113function cmValue<T>(got: unknown, ok: (v: unknown) => boolean, fallback: T): T {
114  const v = (got as { value?: unknown } | null)?.value
115  return ok(v) ? (v as T) : fallback
116}
117const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
118const isFeat = (f: unknown): f is MapFeat =>
119  isObj(f) && typeof f.slug === 'string' && typeof f.name === 'string' && Array.isArray(f.files) && Array.isArray(f.deps)
120let mapOn = false
121let lang: 'de' | 'en' = 'en'
122const tx = (de: string, en: string) => (lang === 'de' ? de : en)
123
124async function getView($: EngineInterface): Promise<FileView> {
125  return (await $.state.get(VIEW)).value ?? { tab: 'files', feature: '' }
126}
127async function setView($: EngineInterface, change: Partial<FileView>): Promise<void> {
128  await update($, VIEW, v => ({ tab: change.tab ?? v?.tab ?? 'files', feature: change.feature ?? v?.feature ?? '' }))
129}
130// The blueprint actions: the Codebase Map's own prompts (from its i18n module) and its own pane.
131// Not through /codebase-map: a command run from a plugin skips that plugin's hooks, and both mods
132// live in the one box plugin.
133type MapAction = 'map' | 'create' | 'update' | 'check' | 'publish' | 'feature-update' | 'feature-publish'
134async function mapAction($: EngineInterface, action: MapAction, feature = '', isIndexPublished = false): Promise<void> {
135  const m = MAP_TEXT[lang]
136  if (action === 'map') {
137    await $.ui.open({ id: 'codebase-map', title: m.paneTitle })
138    return
139  }
140  const text =
141    action === 'create' ? m.promptCreate
142      : action === 'update' ? m.promptUpdate
143        : action === 'check' ? m.promptCheck
144          : action === 'publish' ? m.promptPublishAll
145            : action === 'feature-update' ? m.promptUpdateFeature(feature)
146              : m.promptPublishFeature(feature, isIndexPublished)
147  await $.prompt.fill({ text })
148}
149// A file of a feature: back to the files tab, unfolded and under the cursor.
150async function showInTree($: EngineInterface, rel: string): Promise<void> {
151  const base = posix((await $.session.root()).replace(/[\\/]+$/, ''))
152  const abs = `${base}/${rel.replace(/^\.?\//, '')}`
153  await setView($, { tab: 'files' })
154  const t = await get($)
155  if (!inside(t.root, abs) && t.root !== abs) await reset($, base)
156  await revealPaths($, [abs])
157  await patch($, cur => {
158    const open = new Set(cur.expanded)
159    for (const a of ancestorsOf(abs, cur.root)) open.add(a)
160    return { expanded: [...open], cursor: abs, selected: abs, scroll: null }
161  })
162}
163const ACTIVITY = { plugin: 'mod-box', key: 'ft_activity' } as const
164const PANE = 'filetree'
165const SHIMMER = Object.fromEntries(Object.entries(TONES).map(([k, v]) => [k, { bright: v.bright, dim: v.dim }]))
166const BRANCH_ROW = '#branch'
167const FLASH_MS = 2700
168const RUNNING_MAX_MS = 600_000
169const NO_REPO_DEPTH = 4
170const DOUBLE_MS = 450
171const FIND_LIMIT = 200
172const READ_REVEAL_LIMIT = 12
173const SEARCH_REVEAL_LIMIT = 60
174const ACTIVITY_TTL_MS = 45_000
175const ADD_COLOR = '#98c379'
176const DEL_COLOR = '#e06c75'
177const THEME_FILE = '.local/state/omarchy/current/theme/colors.toml'
178const THEME_POLL_MS = 2000
179const FONT_SCRIPT =
180  'if command -v fc-list >/dev/null 2>&1; then f=$(fc-list ":charset=$1" file | head -n1 | cut -d: -f1); ' +
181  'else f=$(ls "$HOME"/Library/Fonts/*Nerd* /Library/Fonts/*Nerd* 2>/dev/null | head -n1); fi; ' +
182  '[ -n "$f" ] || { echo missing; exit 0; }; m=$(stat -c %Z "$f" 2>/dev/null || stat -f %c "$f"); p=$PPID; ' +
183  'while [ -n "$p" ] && [ "$p" -gt 1 ]; do c=$(ps -o comm= -p "$p" 2>/dev/null); c=$(basename "$c" 2>/dev/null | tr -d " "); case "$c" in ' +
184  'ghostty|kitty|alacritty|Alacritty|foot|footclient|wezterm-gui|konsole|gnome-terminal-|xterm|urxvt|st|Terminal|iTerm2) ' +
185  'e=$(ps -o etime= -p "$p" 2>/dev/null | awk -F\'[-:]\' \'{n=NF; s=$n+60*$(n-1); if (n>2) s+=3600*$(n-2); if (n>3) s+=86400*$(n-3); print s}\'); [ -n "$e" ] && [ $(( $(date +%s) - e )) -lt "$m" ] && echo stale || echo ok; exit 0;; esac; ' +
186  'p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d " "); done; echo ok'
187const PRUNE = ['.git', 'node_modules', 'target', '.venv', '__pycache__', 'dist', '.next']
188const HOME_PRUNE = ['Library', 'AppData', '.Trash']
189const SIZE_TIMEOUT_MS = 15_000
190const SIZE_JOBS = 2
191const SIZE_WALK_LIMIT = 50_000
192
193let blink: Timer | null = null
194let themePoll: Timer | null = null
195let themeMtime: number | null = null
196let generation = 0
197let lastPress = { key: '', at: 0 }
198let noNerd = false
199let glyphSetting = 'auto'
200let openWithSetting = ''
201let previewed: { path: string; text: string; note: string } | null = null
202let previewRows = 20
203let follow = true
204let followClaude = true
205let scanning: Promise<void> | null = null
206let scanJobs: Job[] = []
207let gitRun: Promise<void> | null = null
208let gitNext: Promise<void> | null = null
209let queuedReads: string[] = []
210let showReads = true
211let showWrites = true
212let searchIndex: { root: string; paths: Promise<string[]> } | null = null
213let activityId = 0
214let pointer = true
215let view = { from: 0, max: 0 }
216let nativeScroll = false
217// Mod Box: the engine refuses a Client whose props serialize past 100,000 characters; the desktop
218// gets every row at once, so a big expanded tree (or a long file in the preview) is kept below it.
219const CLIENT_MAX_CHARS = 90_000
220const PREVIEW_MAX_CHARS = 80_000
221
222/** The rows, cut from the end farther from the cursor until they fit a Client's props. */
223export function fitRows<T extends { id: string }>(specs: T[], cursor: string, max = CLIENT_MAX_CHARS): T[] {
224  const sizes = specs.map(r => JSON.stringify(r).length + 1)
225  let total = sizes.reduce((a, b) => a + b, 0)
226  let from = 0
227  let to = specs.length
228  const at = Math.max(0, specs.findIndex(r => r.id === cursor))
229  while (total > max && to - from > 1) {
230    if (at - from > to - 1 - at) total -= sizes[from++]!
231    else total -= sizes[--to]!
232  }
233  return from === 0 && to === specs.length ? specs : specs.slice(from, to)
234}
235let lastSync = 0
236let noDock = false
237let home = ''
238let platform: Promise<'linux' | 'darwin' | 'win32'> | null = null
239let dirty: { root: string; files: Record<string, Change> } = { root: '', files: {} }
240let markId = 0
241let listSeq = 0
242let lastRoot = ''
243let sizeDefault = false
244let sizeEpoch = 0
245let sizeActive = 0
246let sizeQueue: string[] = []
247const sizing = new Set<string>()
248const listLatest = new Map<string, number>()
249const unreadable = new Set<string>()
250const background = new Map<string, Background>()
251
252function osName($: EngineInterface): Promise<'linux' | 'darwin' | 'win32'> {
253  platform ??= (async () => {
254    if ((await $.env.get('OS')) === 'Windows_NT') return 'win32'
255    try {
256      return (await $.process.run(['uname', '-s'], { timeoutMs: 3_000 })).stdout.trim() === 'Darwin' ? 'darwin' : 'linux'
257    } catch {
258      return 'linux'
259    }
260  })()
261  return platform
262}
263
264async function cwdOf($: EngineInterface): Promise<string> {
265  return posix(await $.session.cwd())
266}
267
268function clean(segs: Seg[]): Seg[] {
269  for (const seg of segs) for (const k of Object.keys(seg) as (keyof Seg)[]) if (seg[k] === undefined) delete seg[k]
270  return segs
271}
272
273function faint(hex: string): string {
274  const v = parseInt(hex.slice(1), 16)
275  const ch = (shift: number) => Math.round(0x26 + (((v >> shift) & 255) - 0x26) * 0.3)
276  return `#${[16, 8, 0].map(x => ch(x).toString(16).padStart(2, '0')).join('')}`
277}
278
279async function get($: EngineInterface): Promise<FileTree> {
280  return { ...emptyTree(''), ...(await $.state.get(TREE)).value }
281}
282
283async function put($: EngineInterface, fn: (t: FileTree) => FileTree): Promise<void> {
284  await update($, TREE, cur => fn({ ...emptyTree(''), ...cur }))
285}
286
287function patch($: EngineInterface, fn: (t: FileTree) => Partial<FileTree>) {
288  return put($, t => ({ ...t, ...fn(t) }))
289}
290
291async function activities($: EngineInterface): Promise<Activity[]> {
292  return (await $.state.get(ACTIVITY)).value ?? []
293}
294
295async function setActivities($: EngineInterface, fn: (list: Activity[]) => Activity[]): Promise<void> {
296  await update($, ACTIVITY, cur => fn(cur ?? []).slice(-6))
297}
298
299async function list($: EngineInterface, dir: string): Promise<FileNode[] | null> {
300  try {
301    const entries = await $.fs.list(dir)
302    const resolved = await Promise.all(
303      entries.map(async e => {
304        if (!e.isLink) return { name: e.name, kind: e.kind, mtimeMs: e.mtimeMs, size: e.size, isLink: false }
305        try {
306          const target = await $.fs.stat(join(dir, e.name))
307          return target.kind === 'dir'
308            ? { name: e.name, kind: 'dir' as const, mtimeMs: target.mtimeMs, size: 0, isLink: false }
309            : { name: e.name, kind: 'file' as const, mtimeMs: target.mtimeMs, size: target.size, isLink: true }
310        } catch {
311          return { name: e.name, kind: 'other' as const, mtimeMs: 0, size: 0, isLink: true }
312        }
313      }),
314    )
315    unreadable.delete(dir)
316    return toNodes(dir, resolved)
317  } catch (err) {
318    if (!(await exists($, dir))) return []
319    if (!unreadable.has(dir)) {
320      unreadable.add(dir)
321      $.ui.toast(`could not list ${shortPath(dir)}: ${err instanceof Error ? err.message : String(err)}`)
322    }
323    return null
324  }
325}
326
327async function loadDirs($: EngineInterface, dirs: string[]): Promise<Map<string, FileNode[]>> {
328  const root = (await get($)).root
329  const seq = ++listSeq
330  for (const dir of dirs) listLatest.set(dir, seq)
331  const results = await Promise.all(dirs.map(async dir => [dir, await list($, dir)] as const))
332  const listed = new Map<string, FileNode[]>()
333  for (const [dir, kids] of results) {
334    if (listLatest.get(dir) !== seq) continue
335    listLatest.delete(dir)
336    if (kids) listed.set(dir, kids)
337  }
338  await patch($, t => (t.root === root ? { nodes: replaceChildren(t.nodes, listed) } : {}))
339  return listed
340}
341
342async function git($: EngineInterface, cwd: string, args: string[], timeoutMs = 20_000) {
343  return $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs, env: { GIT_OPTIONAL_LOCKS: '0' } })
344}
345
346async function detectRepo($: EngineInterface): Promise<void> {
347  const t = await get($)
348  if (!t.root) return
349  let top = ''
350  let prefix = ''
351  try {
352    const run = await git($, t.root, ['rev-parse', '--show-prefix', '--show-toplevel'], 5_000)
353    const [p = '', tl = ''] = run.exitCode === 0 ? run.stdout.split('\n') : []
354    prefix = p.trim()
355    top = tl.trim()
356  } catch {
357    top = ''
358  }
359  await patch($, cur => (cur.root === t.root ? { top, prefix } : {}))
360}
361
362async function readGit($: EngineInterface): Promise<void> {
363  const t = await get($)
364  if (!t.root || !t.top) return
365  const root = t.root
366  try {
367    const status = await git($, root, ['status', '--porcelain=v1', '-b', '-z', '--ignored=traditional', '--untracked-files=normal', '--', '.'])
368    if (status.exitCode !== 0) return
369    const parsed = parseGit(status.stdout, root, t.prefix)
370    const diff: Record<string, [number, number]> = {}
371    const head = await git($, root, ['diff', 'HEAD', '--numstat', '-z', '--', '.'])
372    if (head.exitCode === 0) parseNumstat(head.stdout, root, t.prefix, diff)
373    const files = { ...parsed.files }
374    if (parsed.untrackedDirs.length) {
375      const others = await git($, root, ['ls-files', '-o', '--exclude-standard', '-z', '--', ...parsed.untrackedDirs.map(d => d.slice(root.length + 1) || '.')])
376      if (others.exitCode === 0) for (const rel of others.stdout.split('\0').filter(Boolean)) files[join(root, rel)] = 'new'
377    }
378    for (const path of Object.keys(diff)) if (files[path] === 'new') delete diff[path]
379    dirty = { root, files }
380    await patch($, cur =>
381      cur.root === root
382        ? {
383            git: parsed.git,
384            ignored: parsed.ignored,
385            untrackedDirs: parsed.untrackedDirs,
386            branch: parsed.branch,
387            counts: rollCounts(files, root),
388            diff: rollUp(diff, root),
389          }
390        : {},
391    )
392  } catch {
393    return
394  }
395}
396
397function refreshGit($: EngineInterface): Promise<void> {
398  if (!gitRun) {
399    gitRun = readGit($).finally(() => {
400      gitRun = null
401    })
402    return gitRun
403  }
404  const rerun = () => {
405    gitNext = null
406    return refreshGit($)
407  }
408  gitNext ??= gitRun.then(rerun, rerun)
409  return gitNext
410}
411
412async function reset($: EngineInterface, root: string, focus = false): Promise<void> {
413  const prev = await get($)
414  generation += 1
415  blink?.cancel()
416  blink = null
417  lastRoot = root
418  searchIndex = null
419  sizeEpoch += 1
420  sizeQueue = []
421  await put($, () => ({ ...emptyTree(root), showHidden: prev.showHidden, showSize: prev.root ? prev.showSize : sizeDefault }))
422  const title = `Files: ${root.split('/').pop() || root}`
423  if (focus) await $.ui.open({ id: PANE, title, focus: true })
424  else if (!noDock) await $.ui.open({ id: PANE, title })
425  await loadDirs($, [root])
426  await detectRepo($)
427  await refreshGit($)
428}
429
430async function revealPaths($: EngineInterface, paths: string[]): Promise<void> {
431  const tried = new Set<string>()
432  for (let pass = 0; pass < 32; pass++) {
433    const t = await get($)
434    const loaded = new Set(t.nodes.filter(n => n.loaded).map(n => n.id))
435    if (t.nodes.some(n => n.parent === t.root)) loaded.add(t.root)
436    const need = new Set<string>()
437    for (const p of paths) {
438      for (const dir of [t.root, ...ancestorsOf(p, t.root).reverse()]) {
439        if (!loaded.has(dir)) {
440          if (!tried.has(dir)) need.add(dir)
441          break
442        }
443      }
444    }
445    if (need.size === 0) return
446    for (const dir of need) tried.add(dir)
447    await loadDirs($, [...need])
448  }
449}
450
451function pruneArgs(root: string, ignored: string[] = []): string[] {
452  const names = broad(root) ? [...PRUNE, ...HOME_PRUNE] : PRUNE
453  return ['(', ...names.flatMap((name, i) => (i === 0 ? ['-name', name] : ['-o', '-name', name])), ...ignored.flatMap(p => ['-o', '-path', p]), ')', '-prune', '-o']
454}
455
456async function newer($: EngineInterface, paths: string[], sinceMs: number): Promise<string[]> {
457  const stats = await Promise.all(
458    paths.map(async p => {
459      try {
460        return (await $.fs.stat(p)).mtimeMs > sinceMs ? p : ''
461      } catch {
462        return ''
463      }
464    }),
465  )
466  return stats.filter(Boolean)
467}
468
469function broad(root: string): boolean {
470  return root === home || root === '/' || /^[A-Za-z]:\/$/.test(root)
471}
472
473function rootOfDisk(root: string): boolean {
474  return root === '/' || /^[A-Za-z]:\/$/.test(root)
475}
476
477async function changedSince($: EngineInterface, root: string, since: Since, depth: number, ignored: string[] = []): Promise<string[]> {
478  if (rootOfDisk(root)) return []
479  const test = since.mark ? ['-newer', since.mark] : ['-newermt', `@${(since.ms / 1000).toFixed(3)}`]
480  try {
481    const run = await $.process.run(['find', '-H', root, '-xdev', ...(depth ? ['-maxdepth', String(depth)] : []), ...pruneArgs(root, ignored.filter(p => inside(root, p)).slice(0, 40)), ...test, '-print0'], { timeoutMs: 8_000 })
482    return run.stdout.split('\0').filter(p => p && p !== root)
483  } catch {
484    return []
485  }
486}
487
488async function changedInRepo($: EngineInterface, root: string, since: Since, before: Record<string, Change>, ignored: string[]): Promise<{ hits: string[]; gone: string[] }> {
489  if (since.os !== 'win32') return { hits: await changedSince($, root, since, 0, ignored), gone: [] }
490  if (dirty.root !== root) return { hits: [], gone: [] }
491  const entries = Object.entries(dirty.files)
492  const gone = entries.filter(([p, c]) => c === 'del' && before[p] !== 'del').map(([p]) => p)
493  const live = entries.filter(([, c]) => c !== 'del').map(([p]) => p)
494  return { hits: await newer($, live.slice(0, 4_000), since.ms), gone }
495}
496
497type Since = { ms: number; mark: string; os: 'linux' | 'darwin' | 'win32' }
498
499async function sinceNow($: EngineInterface, writes: boolean): Promise<Since> {
500  const ms = await $.clock.now()
501  const os = await osName($)
502  if (os !== 'darwin' || !writes) return { ms, mark: '', os }
503  const mark = `${((await $.env.get('TMPDIR')) || '/tmp').replace(/\/+$/, '')}/filetree-${await $.session.id()}-${++markId}.mark`
504  try {
505    await $.process.run(['touch', mark], { timeoutMs: 3_000 })
506    return { ms, mark, os }
507  } catch {
508    return { ms, mark: '', os }
509  }
510}
511
512async function walkSize($: EngineInterface, dir: string): Promise<number> {
513  let total = 0
514  let seen = 0
515  let level = [dir]
516  while (level.length) {
517    const next: string[] = []
518    for (const d of level) {
519      let entries
520      try {
521        entries = await $.fs.list(d)
522      } catch {
523        continue
524      }
525      for (const e of entries) {
526        if (++seen > SIZE_WALK_LIMIT) return -1
527        if (e.kind === 'dir' && !e.isLink) next.push(join(d, e.name))
528        else if (e.kind === 'file') total += e.size
529      }
530    }
531    level = next
532  }
533  return total
534}
535
536async function dirSize($: EngineInterface, dir: string): Promise<number> {
537  if (rootOfDisk(dir)) return -1
538  if ((await osName($)) === 'win32') return walkSize($, dir)
539  try {
540    const run = await $.process.run(['du', '-skxH', dir], { timeoutMs: SIZE_TIMEOUT_MS })
541    const kb = Number(run.stdout.trim().split(/\s/)[0] || NaN)
542    return Number.isFinite(kb) ? kb * 1024 : -1
543  } catch {
544    return -1
545  }
546}
547
548function pumpSizes($: EngineInterface): void {
549  while (sizeActive < SIZE_JOBS && sizeQueue.length) {
550    const dir = sizeQueue.shift() ?? ''
551    const epoch = sizeEpoch
552    sizing.add(dir)
553    sizeActive += 1
554    void dirSize($, dir)
555      .then(async bytes => {
556        if (epoch === sizeEpoch) return patch($, cur => (inside(cur.root, dir) ? { dirSizes: { ...cur.dirSizes, [dir]: bytes } } : {}))
557        const cur = await get($)
558        if (cur.showSize && inside(cur.root, dir) && !(dir in cur.dirSizes) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
559      })
560      .catch(() => undefined)
561      .finally(() => {
562        sizing.delete(dir)
563        sizeActive -= 1
564        pumpSizes($)
565      })
566  }
567}
568
569function wantSizes($: EngineInterface, dirs: string[]): void {
570  for (const dir of dirs) if (!sizing.has(dir) && !sizeQueue.includes(dir)) sizeQueue.push(dir)
571  pumpSizes($)
572}
573
574async function staleSizes($: EngineInterface, paths?: string[]): Promise<void> {
575  sizeEpoch += 1
576  sizeQueue = []
577  await patch($, cur => {
578    if (!paths) return { dirSizes: {} }
579    const keep: Record<string, number> = {}
580    for (const [dir, bytes] of Object.entries(cur.dirSizes)) if (!paths.some(p => inside(dir, p))) keep[dir] = bytes
581    return { dirSizes: keep }
582  })
583}
584
585function openDirs(t: FileTree): string[] {
586  const open = new Set(t.expanded)
587  return t.nodes.filter(n => n.kind === 'dir' && n.loaded && open.has(n.id)).map(n => n.id)
588}
589
590function keepTop(cur: FileTree, expanded: string[]): Partial<FileTree> {
591  if (followClaude) return { scroll: null }
592  const top = visibleRows(cur)[cur.scroll ?? view.from]?.node.id
593  const at = top ? visibleRows({ ...cur, expanded }).findIndex(r => r.node.id === top) : -1
594  return at < 0 ? {} : { scroll: at }
595}
596
597async function flash($: EngineInterface, tones: Record<string, string>): Promise<void> {
598  const unique = Object.keys(tones)
599  if (unique.length === 0) return
600  const mine = ++generation
601  const root = (await get($)).root
602  blink?.cancel()
603  blink = null
604  await patch($, cur => {
605    if (cur.root !== root) return {}
606    const open = new Set(cur.expanded)
607    const bright = new Set([...(cur.flashOn ? cur.flash.filter(id => !unique.includes(id)) : []), ...unique])
608    const dim = new Set<string>()
609    const all: Record<string, string> = cur.flashOn ? { ...cur.flashTones } : {}
610    if (cur.flashOn) {
611      for (const id of cur.flashDim) dim.add(id)
612    }
613    for (const id of unique) {
614      const tone = tones[id] ?? 'orange'
615      all[id] = tone
616      if (id === BRANCH_ROW) continue
617      const chain = ancestorsOf(id, cur.root)
618      if (!chain.some(a => !open.has(a))) continue
619      for (const a of chain) {
620        if (!open.has(a)) {
621          dim.add(a)
622          all[a] = all[a] ?? tone
623        }
624        open.add(a)
625      }
626    }
627    for (const id of bright) dim.delete(id)
628    return {
629      flash: [...bright],
630      flashDim: [...dim],
631      flashOn: true,
632      flashTones: all,
633      expanded: [...open],
634      ...keepTop(cur, [...open]),
635    }
636  })
637  if (generation !== mine) return
638  blink = $.clock.after(FLASH_MS, () => {
639    if (generation !== mine) return
640    blink = null
641    void patch($, cur => (generation === mine ? { flash: [], flashDim: [], flashOn: false, flashTones: {} } : {}))
642  })
643}
644
645async function followCwd($: EngineInterface): Promise<boolean> {
646  if (!follow) return false
647  const cwd = await cwdOf($)
648  const t = await get($)
649  if (t.root === cwd) return false
650  await reset($, cwd)
651  return true
652}
653
654type Pending = { actions: GitAction[]; ids: number[]; command: string; head: string }
655type Job = { actions: GitAction[]; since: Since; initRepo: boolean; readOnly: boolean }
656type Background = { p: Pending | null; job: Job; reads: string[] }
657type GitOperation = NonNullable<BuiltinToolResults['Bash']['gitOperation']>
658type Outcome = { state: 'done' | 'failed'; label: string; detail: string }
659
660const PROOF: Record<string, (op: GitOperation) => boolean> = {
661  push: op => Boolean(op.push),
662  merge: op => op.branch?.action === 'merged',
663  rebase: op => op.branch?.action === 'rebased',
664  'pr create': op => op.pr?.action === 'created',
665  'pr merge': op => op.pr?.action === 'merged',
666  'pr comment': op => op.pr?.action === 'commented',
667}
668
669async function startGit($: EngineInterface, actions: GitAction[]): Promise<number[]> {
670  const now = await $.clock.now()
671  const ids = actions.map(() => ++activityId)
672  await setActivities($, cur => [
673    ...cur.filter(a => a.state === 'running' || now - a.at < ACTIVITY_TTL_MS),
674    ...actions.map((a, i) => ({
675      id: ids[i] ?? 0,
676      kind: a.kind,
677      label: a.running,
678      state: 'running' as const,
679      detail: '',
680      at: now,
681      tone: a.tone,
682      nerd: a.icon.nerd,
683      plain: a.icon.plain,
684    })),
685  ])
686  return ids
687}
688
689async function headOf($: EngineInterface, cwd: string): Promise<string> {
690  try {
691    const run = await git($, cwd, ['rev-parse', 'HEAD'], 5_000)
692    return run.exitCode === 0 ? run.stdout.trim() : ''
693  } catch {
694    return ''
695  }
696}
697
698async function outcomes($: EngineInterface, p: Pending, ok: boolean, op: GitOperation | undefined): Promise<Outcome[]> {
699  const chain = chainOf(p.command)
700  const after = p.actions.some(a => a.verb === 'commit') && !op?.commit ? await headOf($, (await get($)).root || (await cwdOf($))) : ''
701  return p.actions.map(a => {
702    if (a.verb === 'commit') {
703      const sha = op?.commit?.sha || (after && after !== p.head ? after : '')
704      return sha ? { state: 'done', label: a.done, detail: sha.slice(0, 7) } : { state: 'failed', label: `${a.verb} failed`, detail: '' }
705    }
706    if ((op && PROOF[a.verb]?.(op)) || (ok && chain.and)) return { state: 'done', label: a.done, detail: '' }
707    if (!ok && chain.and && chain.size === 1) return { state: 'failed', label: `${a.verb} failed`, detail: '' }
708    return { state: 'done', label: `ran ${a.kind}`, detail: '' }
709  })
710}
711
712async function finishGit($: EngineInterface, p: Pending, results: Outcome[]): Promise<void> {
713  const now = await $.clock.now()
714  await setActivities($, cur =>
715    cur.map(a => {
716      const i = p.ids.indexOf(a.id)
717      const result = i < 0 ? undefined : results[i]
718      return result ? { ...a, ...result, at: now } : a
719    }),
720  )
721  $.clock.after(ACTIVITY_TTL_MS + 500, () => void setActivities($, cur => cur.filter(a => a.state === 'running' || a.at > now)))
722}
723
724function failAll(p: Pending): Outcome[] {
725  return p.actions.map(a => ({ state: 'failed', label: `${a.verb} failed`, detail: '' }))
726}
727
728function proven(p: Pending | null, results: Outcome[]): GitAction[] {
729  return p ? p.actions.filter((_, i) => results[i]?.state !== 'failed') : []
730}
731
732function taskEnds(text: string): [string, string][] {
733  return [...text.matchAll(/<task-notification>([\s\S]*?)<\/task-notification>/g)].flatMap(m => {
734    const id = /<task-id>([^<]*)<\/task-id>/.exec(m[1] ?? '')?.[1]?.trim()
735    const status = /<status>([^<]*)<\/status>/.exec(m[1] ?? '')?.[1]?.trim()
736    return id && status ? [[id, status] as [string, string]] : []
737  })
738}
739
740async function settleBackground($: EngineInterface, text: string): Promise<void> {
741  if (background.size === 0 || !text.includes('<task-notification>')) return
742  for (const [id, status] of taskEnds(text)) {
743    const task = background.get(id)
744    if (!task) continue
745    background.delete(id)
746    const ok = status === 'completed'
747    const results = task.p ? await outcomes($, task.p, ok, undefined) : []
748    if (task.p) await finishGit($, task.p, results)
749    if (showReads) queuedReads.push(...task.reads)
750    scheduleScan($, { ...task.job, actions: proven(task.p, results) })
751  }
752}
753
754async function gitPaths($: EngineInterface, root: string, prefix: string, committed: boolean): Promise<string[]> {
755  const map = mapper(root, prefix)
756  try {
757    const run = committed
758      ? await git($, root, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-z', 'HEAD'], 5_000)
759      : await git($, root, ['diff', '--cached', '--name-only', '-z'], 5_000)
760    return run.exitCode === 0 ? run.stdout.split('\0').filter(Boolean).flatMap(rel => map(rel) ?? []) : []
761  } catch {
762    return []
763  }
764}
765
766async function gitDir($: EngineInterface, cwd: string): Promise<string> {
767  for (let dir = cwd, i = 0; i < 64; i++) {
768    try {
769      const dot = join(dir, '.git')
770      const st = await $.fs.stat(dot)
771      if (st.kind === 'dir') return dot
772      const ref = /^gitdir:\s*(.+)$/m.exec(String(await $.fs.read(dot)))?.[1]?.trim()
773      if (ref) return resolve(dir, ref, home)
774    } catch {
775      const up = dirname(dir)
776      if (up === dir) return ''
777      dir = up
778      continue
779    }
780    return ''
781  }
782  return ''
783}
784
785async function sync($: EngineInterface, force = false): Promise<void> {
786  const now = await $.clock.now()
787  if (!force && now - lastSync < 2_000) return
788  lastSync = now
789  searchIndex = null
790  const t = await get($)
791  if (!t.root) return
792  await loadDirs($, [t.root, ...openDirs(t)])
793  if (t.top) await refreshGit($)
794}
795
796async function copyPath($: EngineInterface, id: string, absolute: boolean, surface?: string): Promise<void> {
797  const t = await get($)
798  const text = !absolute && id !== t.root && inside(t.root, id) ? id.slice(t.root.endsWith('/') ? t.root.length : t.root.length + 1) : id
799  const done = await $.ui.copy({ text, ...(surface ? { surface: surface as 'terminal' } : {}) })
800  $.ui.toast(done.isCopied ? `Copied ${text}` : 'Could not copy the path')
801}
802
803async function exists($: EngineInterface, path: string): Promise<boolean> {
804  try {
805    await $.fs.stat(path)
806    return true
807  } catch {
808    return false
809  }
810}
811
812async function afterBash($: EngineInterface, jobs: Job[]): Promise<void> {
813  const reads = queuedReads
814  queuedReads = []
815  if (await followCwd($)) return
816  const t = await get($)
817  if (!t.root) return
818  const actions = jobs.flatMap(j => j.actions)
819  const writers = jobs.filter(j => !j.readOnly)
820  const since = (writers.length ? writers : jobs).reduce((a, j) => (j.since.ms < a.ms ? j.since : a), (writers[0] ?? jobs[0])?.since ?? { ms: 0, mark: '', os: 'linux' as const })
821  const writes = !jobs.every(j => j.readOnly)
822  const before = dirty.root === t.root ? dirty.files : {}
823  if (jobs.some(j => j.initRepo)) await detectRepo($)
824  else if (!t.top && (await exists($, join(t.root, '.git')))) await detectRepo($)
825  const probed = await get($)
826  if (probed.top && (writes || probed.top !== t.top)) await refreshGit($)
827  if (writes) {
828    searchIndex = null
829    await staleSizes($)
830  }
831  const fresh = await get($)
832  const ignored = new Set(fresh.ignored)
833  const tones: Record<string, string> = {}
834  if (writes) {
835    const found = fresh.top
836      ? await changedInRepo($, t.root, since, before, fresh.ignored)
837      : { hits: since.os === 'win32' ? [] : await changedSince($, t.root, since, NO_REPO_DEPTH), gone: [] }
838    const hits = found.hits.filter(x => inside(t.root, x) && !underAny(x, ignored, t.root)).slice(0, FIND_LIMIT)
839    await revealPaths($, hits)
840    const loaded = await get($)
841    const dirs = [...new Set([loaded.root, ...openDirs(loaded), ...hits.map(dirname), ...found.gone.map(dirname)])].filter(d => inside(loaded.root, d))
842    const listed = await loadDirs($, dirs)
843    if (!fresh.top && since.os === 'win32')
844      for (const kids of listed.values()) for (const n of kids) if (n.mtime > since.ms && hits.length < FIND_LIMIT) hits.push(n.id)
845    await patch($, cur => {
846      const ids = new Set(cur.nodes.map(n => n.id))
847      return { expanded: cur.expanded.filter(id => ids.has(id)) }
848    })
849    const present = new Set((await get($)).nodes.map(n => n.id))
850    const touchTone = actions.find(a => !['commit', 'push', 'add'].includes(a.verb))?.tone ?? 'orange'
851    if (showWrites) for (const id of hits) if (present.has(id)) tones[id] = touchTone
852  }
853  if (showReads) {
854    const found: string[] = []
855    for (const r of reads) {
856      if (found.length >= READ_REVEAL_LIMIT) break
857      if (r !== t.root && inside(t.root, r) && !underAny(r, ignored, t.root) && (await exists($, r))) found.push(r)
858    }
859    if (found.length) {
860      await revealPaths($, found)
861      const shown = new Set((await get($)).nodes.map(n => n.id))
862      for (const r of found) if (shown.has(r) && !tones[r]) tones[r] = 'purple'
863    }
864  }
865  if (showWrites && actions.length) {
866    const final = await get($)
867    const committed = actions.some(a => a.verb === 'commit')
868    if ((committed || actions.some(a => a.verb === 'add')) && final.top) {
869      const paths = (await gitPaths($, final.root, final.prefix, committed)).filter(x => !underAny(x, ignored, final.root)).slice(0, READ_REVEAL_LIMIT)
870      await revealPaths($, paths)
871      const shown = new Set((await get($)).nodes.map(n => n.id))
872      for (const x of paths) if (shown.has(x)) tones[x] = 'green'
873    }
874    const pushLike = actions.find(a => ['push', 'pull', 'fetch', 'checkout', 'switch', 'branch', 'merge', 'rebase', 'tag'].includes(a.verb) || a.kind.startsWith('gh '))
875    if (pushLike) tones[BRANCH_ROW] = pushLike.tone
876    else if (committed) tones[BRANCH_ROW] = 'green'
877  }
878  await flash($, tones)
879}
880
881function scheduleScan($: EngineInterface, job: Job): void {
882  scanJobs.push(job)
883  if (scanning) return
884  scanning = (async () => {
885    while (scanJobs.length) {
886      const jobs = scanJobs
887      scanJobs = []
888      try {
889        await afterBash($, jobs)
890      } finally {
891        const marks = jobs.map(j => j.since.mark).filter(Boolean)
892        if (marks.length) await $.process.run(['rm', '-f', ...marks], { timeoutMs: 3_000 }).catch(() => undefined)
893      }
894    }
895  })().finally(() => {
896    scanning = null
897  })
898}
899
900async function touched($: EngineInterface, paths: string[], tone: string, show: boolean): Promise<void> {
901  if (tone !== 'purple' && previewed && paths.map(posix).includes(previewed.path)) previewed = null
902  if (await followCwd($)) return
903  const t = await get($)
904  if (!t.root) return
905  const within = paths.map(posix).filter(p => inside(t.root, p))
906  if (within.length === 0) return
907  if (tone !== 'purple') {
908    searchIndex = null
909    await staleSizes($, within)
910    await revealPaths($, within.map(dirname))
911    await loadDirs($, [...new Set(within.map(dirname))].filter(d => inside(t.root, d)))
912    await refreshGit($)
913  } else await revealPaths($, within)
914  if (!show) return
915  const tones: Record<string, string> = {}
916  for (const p of within) tones[p] = tone
917  await flash($, tones)
918}
919
920async function reveal($: EngineInterface, paths: string[]): Promise<void> {
921  if (await followCwd($)) return
922  await revealPaths($, paths)
923  await patch($, cur => {
924    const open = new Set(cur.expanded)
925    for (const p of paths) for (const a of ancestorsOf(p, cur.root)) open.add(a)
926    return { expanded: [...open], cursor: paths[paths.length - 1] ?? cur.cursor, scroll: null }
927  })
928}
929
930async function walk($: EngineInterface, root: string, depth: number, limit: number): Promise<string[]> {
931  const out: string[] = []
932  let level = [root]
933  for (let d = 0; d < depth && level.length && out.length < limit; d++) {
934    const next: string[] = []
935    for (const dir of level) {
936      for (const n of (await list($, dir)) ?? []) {
937        if (n.kind === 'dir') {
938          if (!PRUNE.includes(n.name)) next.push(n.id)
939        } else out.push(n.id)
940      }
941      if (out.length >= limit) break
942    }
943    level = next
944  }
945  return out
946}
947
948async function listAll($: EngineInterface, t: FileTree): Promise<string[] | null> {
949  try {
950    if (!t.top && (await osName($)) === 'win32') return await walk($, t.root, rootOfDisk(t.root) ? 3 : 6, 20_000)
951    const run = t.top
952      ? await git($, t.root, ['ls-files', '-co', '--exclude-standard', '-z'], 10_000)
953      : await $.process.run(['find', '-H', t.root, '-xdev', '-maxdepth', rootOfDisk(t.root) ? '3' : '6', ...pruneArgs(t.root), '-type', 'f', '-print0'], { timeoutMs: 10_000 })
954    if (t.top && run.exitCode !== 0) return null
955    const paths = run.stdout.split('\0').filter(Boolean)
956    if (run.isStdoutTruncated) paths.pop()
957    return paths.map(p => (isAbsolute(p) ? p : join(t.root, p)))
958  } catch {
959    return null
960  }
961}
962
963function indexPaths($: EngineInterface, t: FileTree): Promise<string[]> {
964  if (searchIndex?.root !== t.root) {
965    const index: { root: string; paths: Promise<string[]> } = {
966      root: t.root,
967      paths: listAll($, t).then(paths => {
968        if (paths === null && searchIndex === index) searchIndex = null
969        return paths ?? []
970      }),
971    }
972    searchIndex = index
973  }
974  return searchIndex.paths
975}
976
977async function search($: EngineInterface, query: string): Promise<string[]> {
978  if (!(await get($)).query.trim()) searchIndex = null
979  await patch($, () => ({ query }))
980  const q = query.trim().toLowerCase()
981  if (!q) return []
982  const t = await get($)
983  const hits = (await indexPaths($, t)).filter(p => relative(t.root, p).toLowerCase().includes(q)).slice(0, SEARCH_REVEAL_LIMIT)
984  if ((await get($)).query !== query) return []
985  await revealPaths($, hits)
986  const shown = await get($)
987  const ids = new Set(shown.nodes.map(n => n.id))
988  const stale = [...new Set(hits.filter(p => !ids.has(p)).map(dirname))].filter(d => inside(shown.root, d))
989  if (stale.length) await loadDirs($, stale)
990  return hits
991}
992
993async function jump($: EngineInterface, query: string): Promise<void> {
994  const hits = (await search($, query)).slice(0, 10)
995  await patch($, cur => {
996    const open = new Set(cur.expanded)
997    for (const p of hits) for (const a of ancestorsOf(p, cur.root)) open.add(a)
998    return { query: '', expanded: [...open], cursor: hits[0] ?? cur.cursor, scroll: null }
999  })
1000}
1001
1002async function fontState($: EngineInterface, charset: string): Promise<'ok' | 'stale' | 'missing'> {
1003  try {
1004    const out = (await $.process.run(['sh', '-c', FONT_SCRIPT, 'sh', charset], { timeoutMs: 5_000 })).stdout.trim()
1005    return out === 'stale' || out === 'missing' ? out : 'ok'
1006  } catch {
1007    return 'missing'
1008  }
1009}
1010
1011async function finePointerOk($: EngineInterface): Promise<boolean> {
1012  if (!(await $.env.get('HERDR_ENV'))) return true
1013  try {
1014    const out = (await $.process.run([(await $.env.get('HERDR_BIN_PATH')) || 'herdr', '--version'], { timeoutMs: 3_000 })).stdout
1015    const [major = 0, minor = 0, fix = 0] = (/(\d+)\.(\d+)\.(\d+)/.exec(out) ?? []).slice(1).map(Number)
1016    return major * 1e6 + minor * 1e3 + fix >= 9_001
1017  } catch {
1018    return false
1019  }
1020}
1021
1022const PREVIEW_CHARS = 90_000
1023const PREVIEW_LINE = 2_000
1024
1025function fitSerialized(lines: string[], max: number): string[] {
1026  let size = 0
1027  for (let i = 0; i < lines.length; i++) {
1028    size += JSON.stringify(lines[i]).length
1029    if (size > max) return lines.slice(0, i)
1030  }
1031  return lines
1032}
1033
1034async function loadPreview($: EngineInterface, path: string): Promise<void> {
1035  let text = ''
1036  let note = ''
1037  try {
1038    const stat = await $.fs.stat(path)
1039    if (stat.size > 4_000_000) note = `${formatSize(stat.size)}, too large to preview`
1040    else {
1041      text = await $.fs.read(path)
1042      if (text.slice(0, 8000).includes('\0')) {
1043        text = ''
1044        note = `binary file, ${formatSize(stat.size)}`
1045      }
1046    }
1047  } catch (err) {
1048    note = `could not read: ${String((err as Error)?.message ?? err).split('\n')[0]}`
1049  }
1050  const total = text.length
1051  let lines = text.slice(0, PREVIEW_CHARS).replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '').split('\n')
1052  if (total > PREVIEW_CHARS && lines.length > 1) lines.pop()
1053  lines = lines.map(l => (l.length > PREVIEW_LINE ? `${l.slice(0, PREVIEW_LINE)}…` : l))
1054  const fit = fitSerialized(lines, PREVIEW_CHARS)
1055  if (total > PREVIEW_CHARS || fit.length < lines.length) note = `first ${fit.length.toLocaleString('en')} lines of ${total.toLocaleString('en')} characters`
1056  previewed = { path, text: fit.join('\n'), note }
1057}
1058
1059async function showPreview($: EngineInterface, path: string): Promise<void> {
1060  await loadPreview($, path)
1061  await patch($, () => ({ preview: path, previewTop: 0, selected: path }))
1062}
1063
1064async function closePreview($: EngineInterface): Promise<void> {
1065  await patch($, () => ({ preview: '', previewTop: 0 }))
1066}
1067
1068async function scrollPreview($: EngineInterface, by: number): Promise<void> {
1069  const t = await get($)
1070  const last = Math.max(0, (previewed?.text.split('\n').length ?? 1) - previewRows)
1071  const top = Math.max(0, Math.min(last, t.previewTop + by))
1072  if (top !== t.previewTop) await patch($, () => ({ previewTop: top }))
1073}
1074
1075function previewLines(text: string, top: number, rows: number, markdown: boolean): { text: string; top: number } {
1076  const lines = text.split('\n')
1077  const from = Math.max(0, Math.min(top, lines.length - 1))
1078  let shown = lines.slice(from, from + rows)
1079  if (markdown) {
1080    // Reopen a code fence the window starts inside, so the rest is not read as prose.
1081    let fence = ''
1082    let opener = ''
1083    for (const line of lines.slice(0, from)) {
1084      const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
1085      if (!m) continue
1086      const mark = m[1] ?? ''
1087      if (!fence) {
1088        fence = mark
1089        opener = line.trimStart()
1090      } else if (mark[0] === fence[0] && mark.length >= fence.length && !(m[2] ?? '').trim()) fence = ''
1091    }
1092    if (fence) shown = [opener, ...shown]
1093  }
1094  return { text: shown.join('\n'), top: from }
1095}
1096
1097async function openFile($: EngineInterface, path: string, external = false): Promise<void> {
1098  if (!external && openWithSetting.trim() === 'preview') return showPreview($, path)
1099  const os = await osName($)
1100  const native = (p: string) => (os === 'win32' ? p.replace(/\//g, '\\') : p)
1101  const custom = external ? null : openWithCommand(openWithSetting, native(path), native(dirname(path)))
1102  const { argv, init } = custom ? { argv: os === 'linux' ? ['sh', '-c', LINUX_LAUNCH, 'sh', ...custom] : custom, init: { timeoutMs: 10_000 } } : openCommand(os, path)
1103  try {
1104    const run = await $.process.run(argv, init)
1105    if (run.exitCode !== 0) $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}: ${run.stderr.trim().split('\n')[0] || `exit ${run.exitCode}`}`)
1106  } catch {
1107    $.ui.toast(`could not open ${path} with ${custom?.[0] ?? argv[0]}`)
1108  }
1109}
1110
1111async function toggle($: EngineInterface, n: FileNode): Promise<void> {
1112  if (n.kind === 'dir' && !(await get($)).expanded.includes(n.id)) await loadDirs($, [n.id])
1113  await patch($, t => {
1114    const open = new Set(t.expanded)
1115    if (n.kind === 'dir') open.has(n.id) ? open.delete(n.id) : open.add(n.id)
1116    return { expanded: [...open], cursor: n.id, selected: n.kind === 'dir' ? t.selected : n.id }
1117  })
1118}
1119
1120async function press($: EngineInterface, n: FileNode): Promise<void> {
1121  const now = await $.clock.now()
1122  const isDouble = lastPress.key === n.id && now - lastPress.at < DOUBLE_MS
1123  lastPress = { key: isDouble ? '' : n.id, at: now }
1124  if (!isDouble) {
1125    await toggle($, n)
1126    return
1127  }
1128  await openNode($, n)
1129}
1130
1131async function openNode($: EngineInterface, n: FileNode): Promise<void> {
1132  if (n.kind === 'dir') {
1133    follow = false
1134    await reset($, n.id)
1135  } else await openFile($, n.id)
1136}
1137
1138async function loadTheme($: EngineInterface): Promise<void> {
1139  const path = `${(await $.env.get('HOME')) ?? ''}/${THEME_FILE}`
1140  try {
1141    const stat = await $.fs.stat(path)
1142    if (stat.kind !== 'file') throw new Error('no theme file')
1143    if (stat.mtimeMs === themeMtime) return
1144    await $.state.set(THEME, parseTheme(String(await $.fs.read(path))))
1145    themeMtime = stat.mtimeMs
1146  } catch {
1147    if (themeMtime === 0) return
1148    await $.state.set(THEME, DEFAULT_THEME)
1149    themeMtime = 0
1150  }
1151}
1152
1153function shortPath(path: string): string {
1154  return home && inside(home, path) ? `~${path.slice(home.length)}` : path
1155}
1156
1157export const register: Register = (on, options) => {
1158  mapOn = options?.codebaseMap === 'true' || options?.codebaseMap === true
1159  lang = options?.language === 'de' ? 'de' : 'en'
1160  glyphSetting = typeof options?.glyphs === 'string' ? options.glyphs : 'auto'
1161  const activity = typeof options?.activity === 'string' ? options.activity : 'reads and writes'
1162  showReads = activity.includes('reads')
1163  showWrites = activity.includes('writes')
1164  followClaude = options?.follow !== 'off'
1165  sizeDefault = options?.column === 'size'
1166  openWithSetting = typeof options?.open === 'string' ? options.open : ''
1167  extraApps = readExtraApps(typeof options?.openWith === 'string' ? options.openWith : '')
1168  on('session.start', {}, async ($, e, next) => {
1169    await $.command.register({ name: 'filetree', description: 'Show the file tree; args: [path] (no path = cwd)' })
1170    const windows = (await $.env.get('OS')) === 'Windows_NT'
1171    useDrives(windows)
1172    home = posix(((await $.env.get('HOME')) || (await $.env.get('USERPROFILE')) || '').replace(/[\\/]+$/, ''))
1173    activityId = Math.max(activityId, ...(await activities($)).map(a => a.id))
1174    void (async () => {
1175      pointer = await finePointerOk($)
1176      try {
1177        const remote = Boolean((await $.env.get('SSH_CONNECTION')) || (await $.env.get('SSH_TTY')))
1178        noNerd = !remote && (windows || (await fontState($, 'f04eb')) !== 'ok')
1179      } catch {
1180        noNerd = true
1181      }
1182      await loadTheme($)
1183      themePoll?.cancel()
1184      themePoll = themeMtime ? $.clock.every(THEME_POLL_MS, () => void loadTheme($)) : null
1185      const t = await get($)
1186      if (t.flashOn) await patch($, () => ({ flash: [], flashDim: [], flashOn: false, flashTones: {} }))
1187      if (t.preview) await patch($, () => ({ preview: '', previewTop: 0 }))
1188      await setActivities($, cur => cur.map(a => (a.state === 'running' ? { ...a, state: 'failed', label: `${a.kind} interrupted` } : a)))
1189      const cwd = await cwdOf($)
1190      if (!t.root || t.nodes.length === 0 || (follow && t.root !== cwd)) await reset($, cwd)
1191      else if (!noDock) await $.ui.open({ id: PANE, title: `Files: ${t.root.split('/').pop() || t.root}` })
1192    })()
1193    return next(e)
1194  })
1195
1196  on('command.run', { command: 'filetree' }, async ($, e) => {
1197    // The layout checks are the terminal's: the desktop app docks the pane itself and
1198    // reports no fullscreen layout, so a session no terminal draws skips them.
1199    const terminal = await $.session.surfaces().then(s => s.includes('terminal'), () => true)
1200    if (terminal && !e.presentation.isFullscreen) return { text: 'filetree shows in the sidebar, which needs the fullscreen layout. Run /tui fullscreen, then /filetree.' }
mods/replay-theater/hooks/replay-theater.mjs 252 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified for mod-box (https://github.com/LupusMalusDeviant/mod-box): state, matchers and paths rewritten for the single plugin.
4// Original: https://github.com/anthropics/claude-code-playground/tree/main/claude-code/mods/replay-theater (commit 569c528).
5//
6// Replay Theater: records the file edits Claude makes in a turn, then lets you
7// step through them in a pane, one diff at a time.
8//
9// Claude Code 2.1.280 function hooks. Load with --plugin-dir.
10
11const PANE_ID = "replay-theater";
12const MAX_DIFF_LINES = 12;
13const MAX_LCS_LINES = 400;
14const EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit"]);
15
16// Module state. `pending` fills during a turn; `replay` is the last finished
17// turn's steps, the ones the pane shows.
18const state = { pending: [], replay: [], index: 0, isOpen: false, inBand: false, turns: 0 };
19
20function relPath(cwd, path) {
21  if (!path) return "(unknown file)";
22  if (cwd && path.startsWith(cwd + "/")) return path.slice(cwd.length + 1);
23  return path;
24}
25
26function splitLines(text) {
27  if (text === undefined || text === null || text === "") return [];
28  const lines = String(text).split("\n");
29  if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
30  return lines;
31}
32
33// A line diff. Small inputs get an LCS diff, so unchanged lines show as
34// context; large ones fall back to all-removed then all-added.
35function diffLines(oldText, newText) {
36  const a = splitLines(oldText);
37  const b = splitLines(newText);
38  if (a.length > MAX_LCS_LINES || b.length > MAX_LCS_LINES) {
39    return [...a.map((t) => ({ op: "-", t })), ...b.map((t) => ({ op: "+", t }))];
40  }
41  const n = a.length, m = b.length;
42  const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
43  for (let i = n - 1; i >= 0; i--)
44    for (let j = m - 1; j >= 0; j--)
45      dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
46  const out = [];
47  let i = 0, j = 0;
48  while (i < n && j < m) {
49    if (a[i] === b[j]) { out.push({ op: " ", t: a[i] }); i++; j++; }
50    else if (dp[i + 1][j] >= dp[i][j + 1]) out.push({ op: "-", t: a[i++] });
51    else out.push({ op: "+", t: b[j++] });
52  }
53  while (i < n) out.push({ op: "-", t: a[i++] });
54  while (j < m) out.push({ op: "+", t: b[j++] });
55  return trimContext(out);
56}
57
58// Keep one line of context around each change, so a long file's Write shows
59// the changed lines, not the whole file.
60function trimContext(lines) {
61  if (!lines.some((l) => l.op !== " ")) return lines.slice(0, MAX_DIFF_LINES);
62  const keep = lines.map((l, k) =>
63    l.op !== " " || lines[k - 1]?.op && lines[k - 1].op !== " " || lines[k + 1]?.op && lines[k + 1].op !== " ");
64  const out = [];
65  let skipped = false;
66  lines.forEach((l, k) => {
67    if (keep[k]) { if (skipped && out.length) out.push({ op: "~", t: "⋯" }); out.push(l); skipped = false; }
68    else skipped = true;
69  });
70  return out;
71}
72
73function countChanges(diff) {
74  let add = 0, del = 0;
75  for (const l of diff) { if (l.op === "+") add++; else if (l.op === "-") del++; }
76  return { add, del };
77}
78
79// Turns one tool call into one or more steps.
80async function stepsFor($, e) {
81  let cwd = "";
82  try { cwd = await $.session.cwd(); } catch { /* keep the full path */ }
83  const file = relPath(cwd, e.file_path);
84  if (e.tool === "Edit") {
85    return [{ tool: "Edit", file, diff: diffLines(e.old_string, e.new_string), note: e.replace_all ? "replace all" : "" }];
86  }
87  if (e.tool === "MultiEdit" && Array.isArray(e.edits)) {
88    return e.edits.map((ed, k) => ({
89      tool: "MultiEdit", file, diff: diffLines(ed.old_string, ed.new_string), note: `edit ${k + 1} of ${e.edits.length}`,
90    }));
91  }
92  if (e.tool === "Write") {
93    let before = "";
94    let isNew = true;
95    try {
96      if (e.file_path && (await $.fs.exists(e.file_path))) {
97        before = await $.fs.read(e.file_path);
98        isNew = false;
99      }
100    } catch { /* unreadable: show it as a new file */ }
101    return [{ tool: "Write", file, diff: diffLines(before, e.content), note: isNew ? "new file" : "rewrite" }];
102  }
103  return [];
104}
105
106function hintText() {
107  const n = state.replay.length;
108  return `▶ Replay: ${n} edit${n === 1 ? "" : "s"} (press r)`;
109}
110
111async function openReplay($) {
112  if (!state.replay.length) return false;
113  state.index = 0;
114  state.isOpen = true;
115  // The pane only takes the keyboard while nothing else holds it. Hide the
116  // band first (it held the keys if its button was pressed), then open.
117  $.ui.invalidate("ui.render");
118  await $.clock.sleep(200);
119  const rows = Math.min(MAX_DIFF_LINES + 8, 22);
120  const placed = await $.ui.open({ id: PANE_ID, title: "Replay Theater", focus: true, closeOnEscape: true, rows, columns: 58 });
121  // No room for a pane (a narrow terminal): draw the replay in the band.
122  state.inBand = placed?.isPlaced === false;
123  $.ui.invalidate("ui.render");
124  return true;
125}
126
127// The replay view: one step at a time. Drawn in the Pane, or in the band
128// above the prompt when the surface can't place a pane.
129function replayView($, e, inBand) {
130    const { Box, Text, Button } = $.ui.resolve(e);
131    const maxDiff = inBand ? Math.max(3, Math.min(MAX_DIFF_LINES, (e.maxRows || 20) - 7)) : MAX_DIFF_LINES;
132    const total = state.replay.length;
133    if (!total) return Text({ dimColor: true, children: "No edits to replay." });
134    const k = Math.max(0, Math.min(state.index, total - 1));
135    const step = state.replay[k];
136    const width = Math.max(40, (e.bodyColumns || 100) - 4);
137    const { add, del } = countChanges(step.diff);
138    const shown = step.diff.slice(0, maxDiff);
139
140    const diffRows = shown.map((l, n) => {
141      const color = l.op === "+" ? "green" : l.op === "-" ? "red" : undefined;
142      const line = `${l.op === "~" ? " " : l.op} ${l.t}`.slice(0, width);
143      return Text({ key: `d${n}`, color, dimColor: l.op === " " || l.op === "~", wrap: "truncate-end", children: line });
144    });
145    if (step.diff.length > maxDiff) diffRows.push(Text({ key: "more", dimColor: true, children: `  … ${step.diff.length - maxDiff} more lines` }));
146    if (!shown.length) diffRows.push(Text({ key: "empty", dimColor: true, children: "  (no line changes)" }));
147
148    // The step list: one cell per step, the current one highlighted.
149    const strip = state.replay.map((s, n) =>
150      Text({ key: `s${n}`, inverse: n === k, color: n === k ? "cyan" : undefined, dimColor: n !== k, children: ` ${n + 1} ` }));
151
152    const go = (to) => { state.index = Math.max(0, Math.min(to, total - 1)); $.ui.invalidate("ui.render"); };
153    const close = () => {
154      state.isOpen = false;
155      if (!state.inBand) $.ui.close({ id: PANE_ID });
156      state.inBand = false;
157      $.ui.invalidate("ui.render");
158    };
159
160    return Box({
161      flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingX: 1,
162      children: [
163        Box({ flexDirection: "row", justifyContent: "space-between", children: [
164          Text({ bold: true, color: "magenta", children: "▶ Replay Theater" }),
165          Text({ bold: true, children: `step ${k + 1} of ${total}` }),
166        ] }),
167        Box({ flexDirection: "row", children: strip }),
168        Text({ bold: true, color: "cyan", wrap: "truncate-start", children: step.file }),
169        Box({ flexDirection: "row", gap: 2, children: [
170          Text({ dimColor: true, children: `${step.tool}${step.note ? " · " + step.note : ""}` }),
171          Text({ color: "green", children: `+${add}` }),
172          Text({ color: "red", children: `-${del}` }),
173        ] }),
174        Box({ flexDirection: "column", marginTop: 1, children: diffRows }),
175        Box({ flexDirection: "row", gap: 2, marginTop: 1, children: [
176          Button({ key: "prev", label: "◀ Prev", hotkey: "p", onPress: () => go(k - 1) }),
177          Button({ key: "next", label: "Next ▶", hotkey: "n", autoFocus: true, onPress: () => go(k + 1) }),
178          Button({ key: "close", label: "Close", hotkey: "c", onPress: close }),
179        ] }),
180      ],
181    });
182}
183
184export function register(on, options) {
185  // Slash command: /replay opens the pane.
186  on("session.start", {}, async ($, e, next) => {
187    const r = await next(e);
188    await $.command.register({ name: "replay", description: "Replay Theater: step through the last turn's file edits" });
189    return r;
190  });
191
192  on("command.run", { command: "replay" }, async ($, e) => {
193    const opened = await openReplay($);
194    return { text: opened ? `Replay Theater: ${state.replay.length} edits` : "Replay Theater: no edits in the last turn." };
195  });
196
197  // Record each edit, then let it run. Never blocks the call.
198  on("tool.call", {}, async ($, e, next) => {
199    if (EDIT_TOOLS.has(e.tool)) {
200      try {
201        const steps = await stepsFor($, e);
202        state.pending.push(...steps);
203      } catch { /* recording must never stop the edit */ }
204    }
205    return next(e);
206  });
207
208  on("turn.start", {}, ($, e, next) => {
209    if (!e.agentId) state.pending = [];
210    return next(e);
211  });
212
213  // At the end of a main-loop turn, the pending edits become the replay.
214  on("turn.complete", {}, async ($, e, next) => {
215    const r = await next(e);
216    if (e.agentId || !state.pending.length) return r;
217    state.replay = state.pending;
218    state.pending = [];
219    state.index = 0;
220    state.turns++;
221    $.ui.invalidate("ui.render");
222    return r;
223  });
224
225  // The band above the prompt: the hint and a button that opens the pane.
226  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
227    if (state.isOpen && state.inBand) return replayView($, e, true);
228    if (!state.replay.length || state.isOpen) return next(e);
229    const { Box, Text, Button } = $.ui.resolve(e);
230    return Box({
231      flexDirection: "row", gap: 2, paddingX: 1,
232      children: [
233        Text({ color: "magenta", bold: true, children: hintText() }),
234        Button({ key: "open-replay", label: "Replay", hotkey: "r", onPress: () => openReplay($) }),
235      ],
236    });
237  });
238
239  // The pane.
240  on("ui.render", { component: "Pane" }, ($, e, next) => {
241    if (e.requestId !== PANE_ID) return next(e);
242    return replayView($, e, false);
243  });
244
245  // The person closed the pane (Escape): keep our flag in step.
246  on("ui.close", {}, ($, e, next) => {
247    if (e.id === PANE_ID || e.requestId === PANE_ID) state.isOpen = false;
248    $.ui.invalidate("ui.render");
249    return next(e);
250  });
251}
252
mods/next-steps/hooks/register.tsx 198 lines
1// Modified for mod-box (https://github.com/LupusMalusDeviant/mod-box): language option, token usage reporting, state/matchers rewritten.
2// Original: https://github.com/hamzafer/claude-code-mods/tree/main/mods/next-steps (commit e687416).
3// Next Steps: after each turn, 2 or 3 likely next prompts above the input.
4// In an empty prompt, 1 to 3 drafts one (nothing sends on its own) and 0 dismisses.
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { NextSteps } from '../types'
9import { addUsage, USAGE_STORE } from '../../settings/hooks/usage'
10import type { UsageBook } from '../../settings/hooks/usage'
11
12const MODEL = 'haiku'
13const MAX_ITEMS = 3
14const MAX_CHARS = 60 // what the model is asked for
15const MAX_KEPT = 120 // a longer line is dropped, never cut: a cut draft would send half a prompt
16const MIN_ANSWER = 20 // a reply shorter than this is not worth a model call
17
18// Held by the host, so the list survives a hot reload of this file.
19const steps = atom({ plugin: 'mod-box', key: 'ns_steps' } as const, null as NextSteps | null)
20// True while the list is on screen. where-am-i reads it and leaves out its own "next" meanwhile.
21const active = atom({ plugin: 'mod-box', key: 'ns_active' } as const, false)
22
23// Mod Box: options.language ('de' | 'en') sets the language of the suggestions.
24let language = 'en'
25
26export const register: Register = (on, options) => {
27  language = String(options?.language || 'en')
28  // The turn a suggestion may still be shown for. A submit or a new turn moves it on.
29  let latest = ''
30  let submits = 0
31  // A survey answers bare digits in the band: leave them to it.
32  let hasSurvey = false
33
34  on('prompt.submit', {}, async ($, e, next) => {
35    latest = `submit-${++submits}`
36    await setSteps($, () => null)
37    return next(e)
38  })
39
40  on('turn.start', {}, async ($, e, next) => {
41    latest = e.turnId
42    await setSteps($, () => null)
43    return next(e)
44  })
45
46  on('turn.complete', {}, async ($, e, next) => {
47    const r = await next(e)
48    if (e.agentId) return r // a subagent's turn, not the person's
49    latest = e.turnId // also for a turn that raised no turn.start, and after a hot reload
50    if (e.reason !== 'answer' || e.answer.trim().length < MIN_ANSWER) return r
51    void suggest($, e.turnId, e.answer, () => latest).catch(() => {}) // in the background, so the turn ends at once
52    return r
53  })
54
55  // A digit typed into an empty prompt picks a suggestion instead of landing as text.
56  // A paste carries no key, so a pasted "1" lands as typed.
57  on('prompt.edit', {}, async ($, e, next) => {
58    if (hasSurvey || !e.key || e.key.ctrl || e.key.meta || e.text !== '' || e.start !== 0 || e.end !== 0 || !/^[0-9]$/.test(e.inputText)) return next(e)
59    const s = await read($, steps)
60    if (!s) return next(e)
61    if (e.inputText === '0') {
62      await setSteps($, () => null)
63      return { text: '', cursor: 0 } // consumed: the box stays empty
64    }
65    const pick = s.items[Number(e.inputText) - 1]
66    if (!pick) return next(e)
67    return next({ ...e, inputText: pick }) // a draft: the person edits it or presses Enter
68  })
69
70  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
71    const rest = await next(e) // what other mods and Claude Code draw here stays
72    hasSurvey = e.props.hasSurvey
73    const s = await read($, steps)
74    if (e.props.hasSurvey || e.props.isWorking || !s || s.items.length === 0) return rest
75
76    const { Box, Text } = $.ui.resolve(e)
77    const sep = <Text dimColor>{'  ·  '}</Text>
78    const item = (text: string, i: number) => [
79      <Text color="cyan" bold>{`${i + 1} `}</Text>,
80      <Text>{text}</Text>,
81    ]
82    const dismiss = [<Text color="cyan" bold>{'0 '}</Text>, <Text dimColor>dismiss</Text>]
83
84    const lines =
85      // One per row needs a row each; a short band gets the one row, cut at its end.
86      fitsOnOneLine(s.items, e.props.bodyColumns) || e.props.maxRows < s.items.length + 2 ? (
87        <Text wrap="truncate-end">
88          <Text dimColor>{'next:  '}</Text>
89          {s.items.flatMap((t, i) => [...item(t, i), sep])}
90          {dismiss}
91        </Text>
92      ) : (
93        <Box flexDirection="column">
94          {s.items.map((t, i) => (
95            <Text wrap="truncate-end">
96              <Text dimColor>{i === 0 ? 'next:  ' : '       '}</Text>
97              {item(t, i)}
98              {i === s.items.length - 1 && sep}
99              {i === s.items.length - 1 && dismiss}
100            </Text>
101          ))}
102        </Box>
103      )
104
105    return (
106      <Box flexDirection="column">
107        <Box paddingX={1}>{lines}</Box>
108        {rest}
109      </Box>
110    )
111  })
112}
113
114// Writes the list, then whether it shows, for where-am-i.
115async function setSteps($: EngineInterface, fn: (before: NextSteps | null) => NextSteps | null) {
116  const after = await update($, steps, fn)
117  const showing = after !== null && after.items.length > 0
118  if ((await read($, active)) !== showing) await update($, active, () => showing)
119}
120
121// The whole list on one row, or one suggestion per row when it would not fit.
122export function fitsOnOneLine(items: string[], columns: number) {
123  const width = 'next:  '.length + items.reduce((n, t) => n + 2 + t.length + 5, 0) + '0 dismiss'.length
124  return width <= columns - 2 // the band's padding
125}
126
127async function suggest($: EngineInterface, turnId: string, answer: string, current: () => string) {
128  // Wait for background agents: the main agent picks up again once they finish.
129  const agents = await $.agent.list().catch(() => [])
130  if (agents.some(a => a.status === 'running')) return
131
132  const all = (await $.session.messages().catch(() => [])).filter(m => m.text.trim() !== '')
133  if (all.at(-1)?.role === 'assistant') all.pop() // the latest reply goes in on its own below
134  const messages = all.slice(-6)
135  const r = await $.model.complete({
136    model: MODEL,
137    maxTokens: 200,
138    timeoutMs: 20_000,
139    system:
140      'You predict what a person will type next to their coding assistant, after reading a transcript of their session. ' +
141      `Give 2 or 3 short, concrete follow-up prompts, each an instruction in the imperative, under ${MAX_CHARS} characters, ` +
142      'specific to what just happened (name the file, test or feature). Plain words, no em dashes, no numbering, no quotes. ' +
143      'One per line and nothing else. If nothing sensible follows, reply with the single word NONE.\n' +
144      'Example reply:\nRun the login tests again\nAdd the same check to signup.ts\nOpen a draft PR',
145    prompt: [
146      `<transcript>\n${messages.map(m => `[${m.role === 'user' ? 'person' : 'assistant'}] ${m.text.slice(0, 800)}`).join('\n')}\n</transcript>`,
147      `<latest_reply>\n${answer.slice(0, 2000)}\n</latest_reply>`,
148      'Write the follow-up prompts the person is likely to send next: the lines only, not a reply to the transcript.',
149    ].join('\n\n'),
150  })
151  await recordUsage($, MODEL, r.usage)
152  if (!r.isAnswered) return
153  const items = parseSteps(r.text)
154  // Checked inside the write, so a submit or a new turn that lands meanwhile always wins.
155  await setSteps($, before => (current() !== turnId ? before : items.length > 0 ? { turnId, items } : null))
156}
157
158// Mod Box: token usage of this mod's own model calls, for the window (session in $.state,
159// total in $.store). $ stays in this file; usage.ts only does the arithmetic.
160const USAGE = { plugin: 'mod-box', key: 'mb_usage' } as const
161async function recordUsage($: EngineInterface, model: string, u: Parameters<typeof addUsage>[3] | undefined) {
162  if (!u) return
163  await update($, USAGE, b => addUsage(b, 'nextSteps', model, u)).catch(() => {})
164  const total = await $.store.get(USAGE_STORE).catch(() => null)
165  await $.store.set(USAGE_STORE, addUsage(total as UsageBook | null, 'nextSteps', model, u)).catch(() => {})
166}
167
168// The model's lines, cleaned: no fences, bullets, numbers or em dashes; at most three.
169// A preamble, a "nothing to add" or a line too long to draft whole is dropped.
170export function parseSteps(text: string): string[] {
171  const body = text.replace(/```[a-z]*\n?/gi, '').trim()
172  let lines = body.split('\n')
173  if (body.startsWith('[') || body.startsWith('{')) {
174    try {
175      const o = JSON.parse(body) as unknown
176      const arr = Array.isArray(o) ? o : Object.values(o as Record<string, unknown>).find(Array.isArray)
177      lines = Array.isArray(arr) ? arr.filter((x): x is string => typeof x === 'string') : []
178    } catch {
179      // not JSON after all: read it as lines
180    }
181  }
182  const out: string[] = []
183  for (const line of lines) {
184    const clean = line
185      .replace(/\*\*/g, '')
186      .replace(/^\s*(?:[-*•]|\d+[.):])\s*/, '')
187      .replace(/^["'`]+|["'`,]+$/g, '')
188      .replace(/\s*\u2014\s*/g, ', ')
189      .replace(/\.$/, '')
190      .trim()
191    if (clean === '' || clean.endsWith(':') || clean.length > MAX_KEPT) continue
192    if (/^(none|no |nothing|here are|here's|sure)/i.test(clean) || /^[[\]{}]$/.test(clean)) continue
193    if (!out.some(o => o.toLowerCase() === clean.toLowerCase())) out.push(clean)
194    if (out.length === MAX_ITEMS) break
195  }
196  return out
197}
198