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

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
/mod-box): every mod as a card with a switch, its options, commands and token cost; profiles; sources; health..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.mod-box-add skill. Profiles are rules, so new mods land in the right ones; foreign code stays off until you trust it.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.
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.
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.
claude plugin validate proves that a plugin loads, not that it is harmless.~/.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.gh. Mods that call a model (Next Steps, Catch Me Up, the handoff note, keep-warm) say so and show their token use./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.
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).gh signed in, Git Bash, the signing script);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.
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>
| Switch | Mod | Default | What it does | Costs tokens? |
|---|---|---|---|---|
blastRadius | Blast Radius | on | Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would hit. | no |
packageGuard | Package Guard | on | Before 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 |
secretGuard | Secret Guard | on | Stops 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 |
screenMask | Screen Mask | off | For 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 |
ciWatch | CI Watch | on | Follows 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 |
signGuard | Sign Guard | off | Spots Windows builds and signs the new .exe, .msi and .msix files with your own script (<script> -Exe <file>). Mode ask or auto. | no |
savvyProgress | Savvy Progress | on | Progress bar and agents panel (/agents-info). | no |
filetree | Filetree | on | File 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 |
replayTheater | Replay Theater | on | Steps through the last turn's edits (/replay). | no |
nextSteps | Next Steps | off | Suggests 2–3 next prompts. Claude Code has one built-in suggestion (Tab); this offers two or three. | a Haiku call per answer |
cacheMeter | Cache Meter (with Cache Tax) | on | Shows 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 |
readGuard | Read Guard | on | Holds 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 |
subagentSaver | Subagent Saver | on | Search 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 |
handoff | Handoff Note | on | /handoff writes .claude/handoff.local.md, and a new session offers to continue from it. | one request over the warm cache |
pet | Desktop Pet | off | Windows 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 |
ccSplitDiffView | Split Diff | off | Draws the result of Edit and Write as a side-by-side diff with line numbers; only the drawing changes. | no |
humanInTheLoop | Human in the Loop | off | Claude 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 |
promptRail | Prompt Rail | off | A 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 |
visibleCompact | Visible Compact | on | Before/after view of /compact (/show-last-compact). | no |
catchMeUp | Catch Me Up | off | Live 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 |
codebaseMap | Codebase Map | off | Feature 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 |
| Command | Mod | What it does | ||||||
|---|---|---|---|---|---|---|---|---|
| `/mod-box [profiles | sources | diag]` | Mod Box | opens the window, optionally on a tab | ||||
/pkg-allow [name] | Package Guard | allows a held package for this session | ||||||
/secret-allow | Secret Guard | the exception list: last finds, project and global entries | ||||||
| `/mask [on\ | off]` | Screen Mask | hide secrets and names on screen, or show them again | |||||
/ci · `/ci pr <nr | url> · /ci stop` | CI Watch | state of the watched runs · watch a pull request · stop watching | |||||
/agents-info | Savvy Progress | agents panel | ||||||
/filetree | Filetree | opens the file tree | ||||||
/my-tasks | Human in the Loop | your open tasks from Claude | ||||||
| `/prompt-rail [vertical\ | horizontal\ | off\ | next\ | prev\ | <nr>\ | find <words>]` | Prompt Rail | switch the rail, jump to a prompt |
/replay | Replay Theater | steps through the last turn's edits | ||||||
| `/keepwarm <6h | 90m | always | off | status> · /cache-tax` | Cache Meter | keeps the cache warm · cache costs of this session | ||
/pet [stop] | Desktop Pet | shows the pet, or sends it off | ||||||
/pet <figure> [coat] | Desktop Pet | picks a figure, e.g. /pet cat calico, /pet random; with one pet per session for this session only | ||||||
/handoff | Handoff Note | writes the handoff note | ||||||
/show-last-compact | Visible Compact | before/after of the last /compact | ||||||
/catch-me-up | Catch Me Up | live summary pane | ||||||
| `/codebase-map [create\ | update [feature]\ | check\ | publish [feature]]` | Codebase Map | opens 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 ⚙.
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:
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.
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.
The box ships the skill mod-box-add. Ask Claude something like "add the mod from owner/repo to the Mod Box", and it:
tools/box.mjs import, or installs it natively with tools/box.mjs native install;tools/box.mjs analyze <id> gives a first guess to start from);All instructions the box gives Claude (the skill, the prompts of the mods) are English. Only the language Claude answers in follows the setting.
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:
$.store keys get the mod's prefix.${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.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.
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 (bhooks/register.ts 78 lines1// 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}
78mods/settings/hooks/register.tsx 2114 lines1// 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 lines1// 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}
590mods/secret-guard/hooks/register.tsx 411 lines1// 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}
411mods/screen-mask/hooks/register.tsx 105 lines1// 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}
105mods/package-guard/hooks/register.tsx 159 lines1// 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}
159mods/ci-watch/hooks/register.tsx 453 lines1// 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}
453mods/sign-guard/hooks/register.tsx 188 lines1// 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}
188mods/savvy-progress/hooks/register.tsx 1083 lines1// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
1083mods/filetree/hooks/register.tsx 1854 lines1// 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 lines1// 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}
252mods/next-steps/hooks/register.tsx 198 lines1// 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