The best all-in-one mod pack for Claude Code: a usage limits and context HUD, a guard with undo for rm -rf and git reset --hard, .env and secret protection…

The best all-in-one mod pack for Claude Code. Ten mods in one plugin: your usage limits and context window above the prompt, a guard with undo for rm -rf and git reset --hard, .env files and secrets kept away from the model, and a receipt under every answer that shows what really ran.
Needs Claude Code 2.1.287 or later. Install it from its marketplace on GitHub, inside Claude Code:
/plugin install ultramod --marketplace mertkayacs/ultramod
Type /ultra to open the control pane.
rm -rf, git reset --hard, force pushes, DROP TABLE, terraform destroy and similar until you say yes. In a git work tree it first tries to save a snapshot, and /ultra undo restores it..env, private keys and credential files, and masks known token formats in tool output before Claude sees it..only, deletes a test file or removes assertions..claude/pins.md in the system prompt for the whole session.Five sets switch everything at once: essentials (default), strict, flow, marathon and quiet. /ultra set strict saves the choice for the current project.
All hooks are declared and registered in hooks/register.tsx, one on(...) line each. The mods in hooks/mods/ only compute values (a refusal text, a line, a masked row); the hook in register.tsx turns that value into its answer. A mod that is off in the current set is skipped.
| Hook | When it runs | What it decides |
|---|---|---|
session.start | a session starts | nothing: starts the HUD over, reads the project's set and registers /ultra, then passes the event on unchanged |
classic.SessionStart | a conversation is cleared, resumed or forked | nothing: the same as above, and clears this session's receipts and context warnings, then passes the event on unchanged |
turn.start | a turn starts | nothing: starts the turn timer and the receipt, then passes the event on unchanged |
turn.complete | a turn ends | whether a receipt line goes under the answer (receipts), whether to show a context warning (compact), whether to send a "finished" notification (notify) |
session.end | a session ends | nothing: stops the HUD timer, then passes the event on unchanged |
command.run on /ultra | you type /ultra ... | what /ultra prints; this hook answers only /ultra, the command this plugin registers, and no other command |
ui.render on the band above the prompt and on the Ultra Mod pane | Claude Code draws those two places | what the HUD row and the pane show; everything the plugins beneath draw is kept |
tool.call on Bash, Read, Edit, MultiEdit, Write, NotebookEdit, Grep and Glob | before one of those tool calls runs | whether to refuse the call. guard asks you about a risky shell command (or refuses it in the marathon set), secrets refuses a read of a secret file, tests asks before an edit weakens a test, tidy asks before a new notes file (strict set). The hook returns next(e) to let the call run, or { deny: reason } to refuse it. If the check itself fails, the call is refused |
tool.call on every tool | around a call the check let through | nothing about the call itself, which always runs: it counts the call for the receipt and the HUD, and adds one line for Claude after the same failure repeats (loops) |
session.append | a tool result is about to be stored in the conversation | whether the result holds a known token format to mask (secrets) |
prompt.compose | Claude Code builds the system prompt | whether .claude/pins.md has rules to add (pins) |
classic.Notification | Claude Code says it is waiting for you | whether to send a desktop notification (notify), then passes the event on unchanged |
Ultra Mod has no permission hook: it registers no tool.check hook, answers no permission check and never answers allow. Claude Code's own permission prompt can still follow an Ultra Mod question about the same call. No hook changes Claude Code's permission settings, its default permission mode, Remote Control, agents, configuration or any settings file. The plugin adds one slash command, /ultra, and no tools and no agents.
git reset --hard. It discards uncommitted changes."old_string twice on one file, one sentence is added to that tool result for Claude: stop retrying and rethink. In the flow set it shows a toast instead.[redacted:github]. The same masking runs on refusal text and added lines that other plugins beneath return for a tool call. Your own prompt and Claude's reply are stored as typed..claude/pins.md added after the other system prompt sections./ultra: the control pane and the text /ultra prints.Nothing leaves your computer. Ultra Mod contacts no host, opens no network connection, sends no telemetry and calls no model.
The only text it hands to another program is the desktop notification, which goes to the notifier on your own computer: the project folder name and a short reason, such as "my-app needs you: guard: run git reset --hard?" or "my-app finished in 2m10s". A command quoted there has known token formats masked first and is cut at 120 characters. It goes out through Claude Code's $.process.run call, which starts one program on your computer with the arguments listed below, waits for it to exit and returns its exit code and output; that program shows the notification and exits. A soft chime plays through Claude Code's $.audio.play when notification sounds are on.
The one exception you choose yourself: in the marathon set, compact asks Claude Code to compact the conversation at 88% context through $.session.compact. That is Claude Code's own /compact, which sends the conversation to your model as /compact always does.
None.
Every program starts through $.process.run with an argument list; no shell reads any of it.
git, in the repository you work in, for guard's snapshots and /ultra undo:git rev-parse --is-inside-work-tree, git rev-parse --show-toplevel, git rev-parse --git-path ultramod-index, git rev-parse --verify HEAD: find the repository, its root and the current commit.git add -A and git write-tree, with GIT_INDEX_FILE set to the temporary index ultramod-index: record the work tree as it is, without touching your own index.git commit-tree <tree> [-p <HEAD>] -m "ultramod snapshot: <command>" with the author and committer "Ultra Mod <ultramod@localhost>", then git update-ref refs/worktree/ultramod/snapshots/<time> <commit>: keep the snapshot as a ref. git for-each-ref lists them and git update-ref -d drops the oldest past 20./ultra undo <n>, after you confirm: git rev-parse --git-path ultramod-restore-index, git add -A, git ls-files, git ls-tree -r --name-only --full-tree <snapshot>, git read-tree <snapshot> and git checkout-index --all --force, all on the temporary index ultramod-restore-index, write the snapshot's files back into the work tree.git --version, for /ultra doctor.rm -f <git dir>/ultramod-index (or ultramod-restore-index) on Linux and macOS: remove the temporary index right after use. On Windows the same file is removed by the script this plugin ships, powershell.exe -NoProfile -ExecutionPolicy Bypass -File <plugin>\scripts\remove-index.ps1 -Path <git dir>\ultramod-index, which removes those two file names and nothing else.uname -s and which notify-send or which osascript: find the desktop notifier.notify-send "Claude Code" <message>osascript <plugin>/scripts/notify.applescript "Claude Code" <message>powershell.exe -NoProfile -ExecutionPolicy Bypass -File <plugin>\scripts\notify.ps1 -BodyBase64 <message as base64>; under WSL, wslpath -w <plugin>/scripts/notify.ps1 converts the script path first.The three scripts ship in this plugin's scripts/ folder. They take their text as arguments and never run it.
.claude/pins.md at the root of the current project, only when you run /ultra pin <text>, which appends one line. This is an instructions file: pins adds its lines to the system prompt. Ultra Mod writes no other instructions file and no build, start-up or settings file.refs/worktree/ultramod/snapshots/ in the repository you work in, when guard saves a snapshot before a risky command it lets through.ultramod-index and ultramod-restore-index in that repository's git directory, removed right after use./ultra undo <n> and confirm.OS and WSL_DISTRO_NAME, to tell Windows and WSL apart from Linux and macOS for the notifier and the temporary index cleanup; HOME, or USERPROFILE where HOME is unset, to find your personal pins file ~/.claude/pins.md. These are locations and platform names. Ultra Mod reads no credential, token or key from your machine, and it needs none, so it has no sensitive user_config option..claude/pins.md in the project and ~/.claude/pins.md, when they exist; a test file before an edit to it, to compare the two (tests); whether a file exists before tidy asks about a new notes file.None of what it reads from files goes into a command it runs. The contents of pins.md go only into the system prompt; a test file is only compared with the edit, and only the kind of change found (such as "adds .skip(" or "removes 2 assertions") with the file's path appears in the question, the refusal and the notification. The values passed to $.process.run are the fixed commands listed above, plus these computed values: paths and refs inside your repository (the git directory, the snapshot's tree and commit ids, the snapshot ref name), the snapshot message "ultramod snapshot: <command>" with known token formats masked, the plugin's own script paths, and the notification text described under "What it sends, and where".
Its options (/plugin settings) are the default set, the notification threshold in seconds and whether notifications play a sound.
Source, issues and the full documentation: https://github.com/mertkayacs/ultramod. License: MIT.
hooks/register.tsx 214 lines1import type { AskOptions, AudioClip, CatchHandler, CommandSpec, EngineInterface, Hook, MatchedHook, PaneOpenArgs, ProcessRunInit, Register, ResolveInput, SessionCompactArgs, StateSetOptions, StateSetResult } from 'claude-code'
2import type { UltraApi } from './core/api'
3import type { Runtime } from './core/runtime'
4import { band, checkTool, composeSections, createRuntime, maskRow, notification, pane, REFUSED, sessionEnd, sessionRestart, sessionStart, turnComplete, turnStart, ultraCommand, underAnswer, watchTool } from './core/runtime'
5
6// Every hook Ultra Mod registers is declared in this file, and this file alone
7// touches the engine: `$` goes whole to createApi below, and `next` never
8// leaves the hook that received it. The mods in ./mods compute plain values
9// (a refusal text, a line, a rewritten row) from the facade; each hook here
10// turns that value into its return. No hook answers a permission check
11// (there is no tool.check hook) and none answers allow. The tool.call check
12// returns `next(e)` or `{ deny }`; the tool.call watch returns what `next(e)`
13// returned, with context lines added or tokens masked.
14
15// The facade the mods use. Every raw engine call is written here as a plain
16// `$.noun.method(...)`, so the validator and the directory can account for
17// each capability; nothing else in this file names `$` except the hooks.
18function createApi($: EngineInterface): UltraApi {
19 const stateGet = async (ref: { plugin: string; key: string }): Promise<unknown> => {
20 if (ref.plugin !== 'ultramod') throw new Error('Ultra Mod can only read its own state.')
21 if (ref.key === 'set') return $.state.get({ plugin: 'ultramod', key: 'set' })
22 if (ref.key === 'turn') return $.state.get({ plugin: 'ultramod', key: 'turn' })
23 if (ref.key === 'receipts') return $.state.get({ plugin: 'ultramod', key: 'receipts' })
24 if (ref.key === 'allow') return $.state.get({ plugin: 'ultramod', key: 'allow' })
25 if (ref.key === 'compact') return $.state.get({ plugin: 'ultramod', key: 'compact' })
26 throw new Error('Ultra Mod state key is not declared in the facade.')
27 }
28 const stateSet = async (ref: { plugin: string; key: string }, value: unknown, options?: StateSetOptions): Promise<StateSetResult> => {
29 // The contract in types/index.d.ts gives each key its value type; the
30 // facade's own signature checks it at the mod's call.
31 const held = value as never
32 if (ref.plugin !== 'ultramod') throw new Error('Ultra Mod can only write its own state.')
33 if (ref.key === 'set') return $.state.set({ plugin: 'ultramod', key: 'set' }, held, options)
34 if (ref.key === 'turn') return $.state.set({ plugin: 'ultramod', key: 'turn' }, held, options)
35 if (ref.key === 'receipts') return $.state.set({ plugin: 'ultramod', key: 'receipts' }, held, options)
36 if (ref.key === 'allow') return $.state.set({ plugin: 'ultramod', key: 'allow' }, held, options)
37 if (ref.key === 'compact') return $.state.set({ plugin: 'ultramod', key: 'compact' }, held, options)
38 throw new Error('Ultra Mod state key is not declared in the facade.')
39 }
40 // The one file Ultra Mod writes: the project's pins file, for /ultra pin.
41 const writePins = (root: string, text: string): Promise<void> => {
42 if (root === '') return $.fs.write('.claude/pins.md', text)
43 return $.fs.write(`${root}/.claude/pins.md`, text)
44 }
45 const envGet = (name: string): Promise<string | undefined> => {
46 if (name === 'OS') return $.env.get('OS')
47 if (name === 'HOME') return $.env.get('HOME')
48 if (name === 'USERPROFILE') return $.env.get('USERPROFILE')
49 if (name === 'WSL_DISTRO_NAME') return $.env.get('WSL_DISTRO_NAME')
50 throw new Error('Ultra Mod environment name is not declared in the facade.')
51 }
52 const facade = {
53 state: { get: stateGet, set: stateSet },
54 plugin: { root: $.plugin.root },
55 store: {
56 get: (key: string) => $.store.get(key),
57 set: (key: string, value: unknown) => $.store.set(key, value),
58 },
59 clock: {
60 now: () => $.clock.now(),
61 every: (ms: number, fn: () => void) => $.clock.every(ms, fn),
62 after: (ms: number, fn: () => void) => $.clock.after(ms, fn),
63 },
64 ui: {
65 ask: (question: string, options?: AskOptions) => $.ui.ask(question, options),
66 toast: (text: string) => $.ui.toast(text),
67 log: (text: string) => $.ui.log(text),
68 open: (pane: PaneOpenArgs) => $.ui.open(pane),
69 resolve: (e: ResolveInput) => $.ui.resolve(e),
70 },
71 session: {
72 root: () => $.session.root(),
73 cwd: () => $.session.cwd(),
74 usage: () => $.session.usage(),
75 model: () => $.session.model(),
76 version: () => $.session.version(),
77 compact: (input?: SessionCompactArgs) => $.session.compact(input),
78 },
79 // Every command it starts is listed in plugin/README.md.
80 process: {
81 run: (argv: readonly string[], init?: ProcessRunInit) => $.process.run(argv, init),
82 },
83 fs: {
84 writePins,
85 stat: (path: string) => $.fs.stat(path),
86 exists: (path: string) => $.fs.exists(path),
87 read: (path: string) => $.fs.read(path),
88 },
89 audio: {
90 play: (clip: AudioClip) => $.audio.play(clip),
91 },
92 command: {
93 register: (command: CommandSpec) => $.command.register(command),
94 },
95 env: { get: envGet },
96 }
97 return facade as unknown as UltraApi
98}
99
100// Built again on every load from the plugin's options; the hooks below read it.
101let runtime: Runtime = createRuntime()
102
103// Starts the HUD over, reads the project's set and registers /ultra.
104const startSession: Hook<'session.start'> = async ($, e, next) => {
105 await sessionStart(createApi($), runtime, e)
106 return next(e)
107}
108
109// A cleared, resumed or forked conversation: the same, plus fresh receipts and context warnings.
110const restartSession: Hook<'classic.SessionStart'> = async ($, e, next) => {
111 await sessionRestart(createApi($), runtime, e)
112 return next(e)
113}
114
115// Starts the turn timer and the receipt for this turn.
116const startTurn: Hook<'turn.start'> = async ($, e, next) => {
117 await turnStart(createApi($), runtime, e)
118 return next(e)
119}
120
121// Ends the turn: stores the receipt, checks the context fill, sends a
122// notification for a long turn, and adds the receipt line under the answer.
123const completeTurn: Hook<'turn.complete'> = async ($, e, next) => {
124 const line = await turnComplete(createApi($), runtime, e)
125 if (line === null) return next(e)
126 const result = await next(e)
127 return { ...result, text: underAnswer(result.text, e.answer, line) }
128}
129
130const endSession: Hook<'session.end'> = async ($, e, next) => {
131 await sessionEnd(createApi($), runtime, e)
132 return next(e)
133}
134
135// Answers /ultra, the command this plugin registers, and only that command.
136const runUltra: Hook<'command.run'> = async ($, e) => {
137 const answer = await ultraCommand(createApi($), runtime, e.args)
138 return { text: answer.text }
139}
140
141// Draws the Ultra Mod pane, and the HUD rows above the prompt over what the plugins beneath draw.
142const render: MatchedHook<'ui.render', { component: readonly ['AbovePrompt', 'Pane'] }> = async ($, e, next) => {
143 if (e.component === 'Pane' && e.requestId === 'ultramod') return pane(createApi($), runtime, e)
144 if (e.component !== 'AbovePrompt') return next(e)
145 const rows = await band(createApi($), runtime, e)
146 if (rows === null) return next(e)
147 const { Box } = $.ui.resolve(e)
148 return <Box flexDirection="column">{rows}{await next(e)}</Box>
149}
150
151// Before a call of the tools these mods read runs: guard, secrets, tests and
152// tidy may refuse it.
153const checkToolCall: Hook<'tool.call'> = async ($, e, next) => {
154 const refusal = await checkTool(createApi($), runtime, e)
155 if (refusal !== null) return { deny: refusal }
156 return next(e)
157}
158
159// A failed check refuses the call it had not let through yet.
160const refuseToolCall: CatchHandler<Hook<'tool.call'>> = ($, e, next) => {
161 if (next.called) return next(e)
162 return { deny: REFUSED }
163}
164
165// Around a tool call the checks let through: receipts and the HUD count it,
166// loops adds a line after the same failure repeats, secrets masks tokens in
167// refusal text and context lines from the hooks beneath.
168const watchToolCall: Hook<'tool.call'> = async ($, e, next) => {
169 const after = await watchTool(createApi($), runtime, e)
170 const result = await next(e)
171 const change = await after(result)
172 if (change === null) return result
173 if (change.deny !== undefined) return { deny: change.deny }
174 if (result.deny !== undefined) return result
175 return { ...result, context: change.context }
176}
177
178// Masks known token formats in a tool result before it is stored.
179const maskSecrets: Hook<'session.append'> = async ($, e, next) => {
180 const content = await maskRow(createApi($), runtime, e)
181 if (content === null) return next(e)
182 return next({ ...e, message: { ...e.message, content } })
183}
184
185// Adds the lines of .claude/pins.md to the system prompt.
186const addPins: Hook<'prompt.compose'> = async ($, e, next) => {
187 const added = await composeSections(createApi($), runtime, e)
188 if (added.length === 0) return next(e)
189 const composed = await next(e)
190 return { sections: [...composed.sections, ...added] }
191}
192
193// Sends a desktop notification when Claude Code waits for the person.
194const notifyWaiting: Hook<'classic.Notification'> = async ($, e, next) => {
195 await notification(createApi($), runtime, e)
196 return next(e)
197}
198
199export const register: Register = (on, options) => {
200 runtime = createRuntime(options)
201 on('session.start', startSession)
202 on('classic.SessionStart', restartSession)
203 on('turn.start', startTurn)
204 on('turn.complete', completeTurn)
205 on('session.end', endSession)
206 on('command.run', { command: 'ultra' }, runUltra)
207 on('ui.render', { component: ['AbovePrompt', 'Pane'] }, render)
208 on('tool.call', { tool: ['Bash', 'Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'Grep', 'Glob'] }, checkToolCall).catch(refuseToolCall)
209 on('tool.call', watchToolCall)
210 on('session.append', maskSecrets)
211 on('prompt.compose', addPins)
212 on('classic.Notification', notifyWaiting)
213}
214hooks/core/api.ts 20 lines1import type { AskOptions, EngineInterface } from 'claude-code'
2
3export type UltraEnvName = 'OS' | 'HOME' | 'USERPROFILE' | 'WSL_DISTRO_NAME'
4
5// Mods see only methods implemented by the facade.
6export type UltraApi = {
7 plugin: Pick<EngineInterface['plugin'], 'root'>
8 ui: Pick<EngineInterface['ui'], 'open' | 'resolve'> & { ask: (question: string, options?: AskOptions) => Promise<string>; toast: (text: string) => void; log: (text: string) => void }
9 session: Pick<EngineInterface['session'], 'root' | 'cwd' | 'model' | 'version' | 'compact'> & { usage: () => ReturnType<EngineInterface['session']['usage']> }
10 process: Pick<EngineInterface['process'], 'run'>
11 // The one file Ultra Mod writes is <project root>/.claude/pins.md.
12 fs: { stat: (path: string) => ReturnType<EngineInterface['fs']['stat']>; exists: (path: string) => Promise<boolean>; read: (path: string) => Promise<string>; writePins: (root: string, text: string) => Promise<void> }
13 store: Pick<EngineInterface['store'], 'get' | 'set'>
14 clock: Pick<EngineInterface['clock'], 'now' | 'every' | 'after'>
15 audio: { play: (clip: Parameters<EngineInterface['audio']['play']>[0]) => Promise<void> }
16 command: Pick<EngineInterface['command'], 'register'>
17 env: { get: (name: UltraEnvName) => ReturnType<EngineInterface['env']['get']> }
18 state: Pick<EngineInterface['state'], 'get' | 'set'>
19}
20hooks/core/runtime.ts 253 lines1import type { UltraApi } from './api'
2import type { Args, Frozen, PluginOptions, PromptComposeSection, RenderInput, RenderNode } from 'claude-code'
3import type { AfterTool, AppendRow, CommandAnswer, RowContent, Step, ToolCall, ToolResult, UltraMod } from './mod'
4import { runCommand } from './commands'
5import { renderPane } from './pane'
6import { createSets } from './sets'
7import type { SetsEngine } from './sets'
8import { bandRows } from '../mods/hud'
9import type { HudMod } from '../mods/hud'
10import { createMods } from '../mods/index'
11
12// What the hooks in register.tsx call. Each function takes the facade, runs
13// the enabled mods in order and hands back a plain value; none of them sees
14// the engine's `$` or `next`, so every answer a hook gives is written in
15// register.tsx itself.
16//
17// Failure rules: a check that fails refuses the tool call (fail closed);
18// any other step that fails is skipped and the rest go on (fail open).
19
20export type Runtime = {
21 sets: SetsEngine
22 mods: readonly UltraMod[]
23 hud: HudMod
24}
25
26export function createRuntime(options: PluginOptions = {}): Runtime {
27 const sets = createSets(options)
28 const { mods, hud } = createMods(sets)
29 return { sets, mods, hud }
30}
31
32export const REFUSED = 'Ultra Mod could not complete its safety check. Retry after checking /ultra doctor.'
33
34// The steps of one kind whose mod is on and whose filter takes the event.
35async function steps<E>(api: UltraApi, runtime: Runtime, kind: 'watch' | 'turnComplete' | 'append' | 'compose', e: E): Promise<{ mod: UltraMod; step: Step<E, unknown> }[]> {
36 const out: { mod: UltraMod; step: Step<E, unknown> }[] = []
37 for (const mod of runtime.mods) {
38 const step = mod[kind] as unknown as Step<E, unknown> | undefined
39 if (!step) continue
40 try {
41 if (!await runtime.sets.enabled(api, mod.id)) continue
42 if (step.when && !step.when(e)) continue
43 } catch {
44 continue
45 }
46 out.push({ mod, step })
47 }
48 return out
49}
50
51// Runs one lifecycle kind in mod order; a failing step is skipped.
52type LifecycleKind = 'sessionStart' | 'sessionRestart' | 'turnStart' | 'sessionEnd' | 'notification'
53
54async function each(api: UltraApi, runtime: Runtime, kind: LifecycleKind, e: unknown): Promise<void> {
55 for (const mod of runtime.mods) {
56 const step = mod[kind] as unknown as Step<unknown, unknown> | undefined
57 if (!step) continue
58 try {
59 if (!await runtime.sets.enabled(api, mod.id)) continue
60 if (step.when && !step.when(e)) continue
61 await step.run(api, e)
62 } catch {
63 // One mod's failure leaves the others running.
64 }
65 }
66}
67
68// The set is read before any mod step, as the hooks used to do through the dispatcher.
69export async function ready(api: UltraApi, runtime: Runtime): Promise<void> {
70 await runtime.sets.ensure(api)
71}
72
73const register = (api: UltraApi) =>
74 api.command.register({ name: 'ultra', description: 'Control Ultra Mod and switch sets', argumentHint: '[set <name> | sets | reset | doctor | help]', immediate: true })
75
76export async function sessionStart(api: UltraApi, runtime: Runtime, e: Frozen<Args<'session.start'>>): Promise<void> {
77 runtime.hud.stop()
78 await runtime.sets.hydrate(api)
79 await register(api)
80 await ready(api, runtime)
81 await each(api, runtime, 'sessionStart', e)
82}
83
84// classic.SessionStart: a cleared, resumed or forked conversation starts over.
85export async function sessionRestart(api: UltraApi, runtime: Runtime, e: Frozen<Args<'classic.SessionStart'>>): Promise<void> {
86 if (['clear', 'resume', 'fork'].includes(e.source)) {
87 runtime.hud.stop()
88 await runtime.sets.hydrate(api)
89 await register(api)
90 }
91 await ready(api, runtime)
92 await each(api, runtime, 'sessionRestart', e)
93}
94
95export async function turnStart(api: UltraApi, runtime: Runtime, e: Frozen<Args<'turn.start'>>): Promise<void> {
96 try {
97 await runtime.hud.start(api, e)
98 } catch {
99 runtime.hud.stop()
100 }
101 await ready(api, runtime)
102 await each(api, runtime, 'turnStart', e)
103}
104
105// The lines to show under the answer, joined; null when there are none.
106export async function turnComplete(api: UltraApi, runtime: Runtime, e: Frozen<Args<'turn.complete'>>): Promise<string | null> {
107 try {
108 await runtime.hud.complete(api, e)
109 } catch {
110 runtime.hud.stop()
111 }
112 await ready(api, runtime)
113 const lines: string[] = []
114 for (const { step } of await steps(api, runtime, 'turnComplete', e)) {
115 try {
116 const line = await step.run(api, e)
117 if (typeof line === 'string' && line !== '') lines.push(line)
118 } catch {
119 // A failed mod adds no line.
120 }
121 }
122 return lines.length ? lines.join('\n') : null
123}
124
125// The text under the answer: a line already there from a plugin beneath
126// stays, and the receipt goes after it.
127export function underAnswer(below: string, answer: string, line: string): string {
128 return below && below !== answer ? `${below}\n${line}` : line
129}
130
131export async function sessionEnd(api: UltraApi, runtime: Runtime, e: Frozen<Args<'session.end'>>): Promise<void> {
132 runtime.hud.stop()
133 await ready(api, runtime)
134 await each(api, runtime, 'sessionEnd', e)
135}
136
137// tool.call before the call runs: the first refusal in mod order, or null.
138// A check that fails refuses, since the call has not run yet.
139export async function checkTool(api: UltraApi, runtime: Runtime, e: ToolCall): Promise<string | null> {
140 await ready(api, runtime)
141 for (const mod of runtime.mods) {
142 const step = mod.check
143 if (!step) continue
144 try {
145 if (!await runtime.sets.enabled(api, mod.id)) continue
146 if (step.when && !step.when(e)) continue
147 const refusal = await step.run(api, e)
148 if (refusal !== null) return refusal
149 } catch {
150 return REFUSED
151 }
152 }
153 return null
154}
155
156// What the watchers changed in a tool result: the refusal text and the
157// context lines, as they should now read. Null when nothing changed.
158export type ToolChange = { deny: string | undefined; context: readonly string[] }
159
160// tool.call around a call that every check let through: the watchers start
161// in mod order before the call and see its result in reverse order, as
162// nested middleware would. A failing watcher leaves the result as it was.
163export async function watchTool(api: UltraApi, runtime: Runtime, e: ToolCall): Promise<(result: ToolResult) => Promise<ToolChange | null>> {
164 const afters: AfterTool[] = []
165 try {
166 await ready(api, runtime)
167 for (const { step } of await steps(api, runtime, 'watch', e)) {
168 try {
169 const after = await step.run(api, e)
170 if (typeof after === 'function') afters.push(after as AfterTool)
171 } catch {
172 // A watcher that fails before the call only misses this one.
173 }
174 }
175 } catch {
176 // Without the set no watcher runs; the call goes on as it is.
177 }
178 return async result => {
179 let out = result
180 for (const after of afters.reverse()) {
181 try {
182 out = await after(out)
183 } catch {
184 // Keep the result the failed watcher was given.
185 }
186 }
187 if (out.deny === result.deny && sameLines(out.context, result.context)) return null
188 return { deny: out.deny, context: out.context ?? [] }
189 }
190}
191
192function sameLines(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
193 const left = a ?? []
194 const right = b ?? []
195 return left.length === right.length && left.every((line, index) => line === right[index])
196}
197
198// session.append: the row's content with secrets masked, or null to store it as it is.
199export async function maskRow(api: UltraApi, runtime: Runtime, e: AppendRow): Promise<RowContent | null> {
200 await ready(api, runtime)
201 let content: RowContent | null = null
202 for (const { step } of await steps(api, runtime, 'append', e)) {
203 try {
204 const input = content === null ? e : { ...e, message: { ...e.message, content } }
205 const rewritten = await step.run(api, input as AppendRow)
206 if (rewritten !== null) content = rewritten as RowContent
207 } catch {
208 // A failed mod leaves the row as the mods before it left it.
209 }
210 }
211 return content
212}
213
214// prompt.compose: the sections the mods add after the others.
215export async function composeSections(api: UltraApi, runtime: Runtime, e: Frozen<Args<'prompt.compose'>>): Promise<PromptComposeSection[]> {
216 await ready(api, runtime)
217 const added: PromptComposeSection[] = []
218 for (const { step } of await steps(api, runtime, 'compose', e)) {
219 try {
220 const section = await step.run(api, e)
221 if (section) added.push(section as PromptComposeSection)
222 } catch {
223 // A failed mod adds nothing.
224 }
225 }
226 return added
227}
228
229export async function notification(api: UltraApi, runtime: Runtime, e: Frozen<Args<'classic.Notification'>>): Promise<void> {
230 await ready(api, runtime)
231 await each(api, runtime, 'notification', e)
232}
233
234// /ultra and its subcommands.
235export async function ultraCommand(api: UltraApi, runtime: Runtime, args: string): Promise<CommandAnswer> {
236 await ready(api, runtime)
237 return runCommand(api, args, runtime.sets, runtime.mods, () => runtime.hud.sync(api))
238}
239
240// The Ultra Mod pane.
241export function pane(api: UltraApi, runtime: Runtime, e: Frozen<RenderInput<'Pane'>>): ReturnType<typeof renderPane> {
242 return renderPane(api, e, runtime.sets, runtime.mods, () => runtime.hud.sync(api))
243}
244
245// The rows Ultra Mod draws above the prompt, or null; a drawing that fails draws nothing.
246export async function band(api: UltraApi, runtime: Runtime, e: Frozen<RenderInput<'AbovePrompt'>>): Promise<RenderNode[] | null> {
247 try {
248 return await bandRows(api, e, runtime.sets, runtime.mods)
249 } catch {
250 return null
251 }
252}
253hooks/core/mod.ts 70 lines1import type { UltraApi } from './api'
2import type { Args, EventResult, Frozen, PromptComposeSection, RenderInput, RenderNode, ThemeKey } from 'claude-code'
3import type { UltraModSettings, UltraSet } from '../../types/index'
4
5// Every engine hook lives in register.tsx. A mod is plain data and functions
6// that the hooks there call in mod order; none of them sees the engine's `$`
7// or `next`. A mod answers with a value (a refusal text, a line, a rewritten
8// row) and register.tsx turns that value into the hook's return.
9// band and pane contribute drawings; commands answer /ultra subcommands.
10export type ModId = 'hud' | 'receipts' | 'guard' | 'secrets' | 'tests' | 'notify' | 'compact' | 'loops' | 'pins' | 'tidy'
11
12export type ToolCall = Frozen<Args<'tool.call'>>
13export type ToolResult = EventResult<'tool.call'>
14export type AppendRow = Frozen<Args<'session.append'>>
15export type RowContent = AppendRow['message']['content']
16
17// A step that runs for the events its filter accepts.
18export type Step<E, R> = {
19 when?: (e: E) => boolean
20 run: (api: UltraApi, e: E) => R | Promise<R>
21}
22
23// What a mod does with the result of a tool call it watched: the result,
24// unchanged or with lines added for Claude.
25export type AfterTool = (result: ToolResult) => ToolResult | Promise<ToolResult>
26
27export type BandPart = { node: RenderNode; columns: number }
28export type BandContext = {
29 api: UltraApi
30 e: Frozen<RenderInput<'AbovePrompt'>>
31 set: UltraSet
32 settings: UltraModSettings
33 columns: number
34 contextColor: ThemeKey
35}
36export type PaneContext = {
37 api: UltraApi
38 e: Frozen<RenderInput<'Pane'>>
39 set: UltraSet
40 settings: UltraModSettings
41}
42export type CommandAnswer = { text: string }
43export type SubcommandHandler = (api: UltraApi, args: string) => CommandAnswer | null | Promise<CommandAnswer | null>
44
45export interface UltraMod {
46 id: ModId
47 // tool.call before the call runs: the refusal text Claude reads, or null to
48 // let the call through. A step that fails refuses the call.
49 check?: Step<ToolCall, string | null>
50 // tool.call around a call every check let through: what to do with its
51 // result, or null to leave it alone. A step that fails is skipped.
52 watch?: Step<ToolCall, AfterTool | null>
53 sessionStart?: Step<Frozen<Args<'session.start'>>, void>
54 // classic.SessionStart, for a cleared, resumed or forked conversation.
55 sessionRestart?: Step<Frozen<Args<'classic.SessionStart'>>, void>
56 turnStart?: Step<Frozen<Args<'turn.start'>>, void>
57 // turn.complete: a line to show under the answer, or null.
58 turnComplete?: Step<Frozen<Args<'turn.complete'>>, string | null | void>
59 sessionEnd?: Step<Frozen<Args<'session.end'>>, void>
60 // session.append: the row's content rewritten, or null to store it as is.
61 append?: Step<AppendRow, RowContent | null>
62 // prompt.compose: a section to add after the others, or null.
63 compose?: Step<Frozen<Args<'prompt.compose'>>, PromptComposeSection | null>
64 // classic.Notification: work done while Claude Code tells the person it waits.
65 notification?: Step<Frozen<Args<'classic.Notification'>>, void>
66 band?: (ctx: BandContext) => Promise<BandPart | null> | BandPart | null
67 commands?: Record<string, SubcommandHandler>
68 pane?: (ctx: PaneContext) => Promise<RenderNode | null> | RenderNode | null
69}
70hooks/core/commands.ts 61 lines1import type { UltraApi } from './api'
2import type { UltraMod } from './mod'
3import type { SetsEngine } from './sets'
4import { detectNotifier } from './notifier'
5import { isSetName, setLabel, setNames, settingsFor } from './sets'
6
7async function gitState(api: UltraApi): Promise<'available' | 'unavailable'> {
8 try {
9 return (await api.process.run(['git', '--version'])).exitCode === 0 ? 'available' : 'unavailable'
10 } catch {
11 return 'unavailable'
12 }
13}
14
15export async function runCommand(api: UltraApi, args: string, sets: SetsEngine, mods: readonly UltraMod[], sync: () => Promise<void>) {
16 const [command = '', ...rest] = args.trim().split(/\s+/)
17 const value = rest.join(' ')
18 if (!command) {
19 await api.ui.open({ id: 'ultramod', title: 'Ultra Mod', focus: true, closeOnEscape: true })
20 return { text: 'Controls opened.' }
21 }
22 if (command === 'set') {
23 if (!isSetName(value)) return { text: `Choose a set: ${setNames.join(', ')}.` }
24 const set = await sets.switch(api, value)
25 await sync()
26 return { text: `Set: ${setLabel(set)}.` }
27 }
28 if (command === 'sets') return { text: setNames.join('\n') }
29 if (command === 'reset') {
30 const set = await sets.reset(api)
31 await sync()
32 return { text: `Overrides reset: ${setLabel(set)}.` }
33 }
34 if (command === 'help') return { text: '/ultra [set <name> | sets | reset | doctor | help]\nOther mod commands: undo, allow, pin, pins (when implemented and enabled).' }
35 if (command === 'doctor') {
36 const set = await sets.current(api)
37 const [version, notifier, git] = await Promise.all([api.session.version(), detectNotifier(api), gitState(api)])
38 const lines = [
39 'Version 1.0.5',
40 `Claude Code ${version.version}`,
41 `Notifier: ${notifier}`,
42 `Git: ${git}`,
43 `Set: ${setLabel(set)}`,
44 `Overrides: ${Object.keys(set.overrides).join(', ') || 'none'}`,
45 ]
46 for (const mod of mods) {
47 const settings = await settingsFor(api, mod.id)
48 lines.push(`${mod.id}: ${settings.enabled ? 'on' : 'off'} (${settings.mode})`)
49 }
50 return { text: lines.join('\n') }
51 }
52 for (const mod of mods) {
53 const handler = mod.commands?.[command]
54 if (handler && await sets.enabled(api, mod.id)) {
55 const answer = await handler(api, value)
56 if (answer !== null) return answer
57 }
58 }
59 return { text: `Unknown command: ${command}. Run /ultra help.` }
60}
61hooks/core/pane.tsx 86 lines1import type { UltraApi } from './api'
2import type { Frozen, RenderInput, RenderNode } from 'claude-code'
3import type { ModId, UltraMod } from './mod'
4import { hudRow } from '../mods/hud'
5import type { SetsEngine } from './sets'
6import { modIds, setLabel, setNames } from './sets'
7import type { UltraModSettings, UltraSetName } from '../../types/index'
8
9// One sentence per mod and mode: what the mod does now, said once, with no
10// separate mode column to repeat it.
11const sentences: Record<ModId, Record<string, string>> = {
12 guard: { ask: 'Asks before risky commands run.', deny: 'Refuses risky commands.', log: 'Runs risky commands and logs them.', off: 'Risky commands run without a check.' },
13 secrets: { on: 'Blocks secret files and redacts leaks.', strict: 'Blocks secret files and env dumps.', off: 'Secret reads are not blocked.' },
14 tests: { ask: 'Asks before an edit weakens tests.', deny: 'Refuses edits that weaken tests.', off: 'Edits to tests go unchecked.' },
15 tidy: { ask: 'Asks before a new doc file is written.', deny: 'Refuses new docs outside the allowlist.', off: 'New documentation files are allowed.' },
16 loops: { nudge: 'Stops retry loops with a suggestion.', warn: 'Warns on repeat failures.', off: 'Repeat failures pass silently.' },
17 receipts: { tools: 'A receipt after each turn that used tools.', always: 'A receipt after every turn.', issues: 'A receipt when a turn had a problem.', off: 'No receipts are shown.' },
18 compact: { 'warn+offer': 'Warns at 70% and offers to compact.', warn: 'Warns when context passes 70%.', auto: 'Compacts on its own at 88%.', off: 'Context usage is not watched.' },
19 notify: { on: 'Notices when a turn finishes or waits.', off: 'No notices are sent.' },
20 pins: { 'if file': 'Adds .claude/pins.md rules to the prompt.', off: 'Pinned rules are not read.' },
21 hud: { full: 'Shows context, limits, turns and cost.', compact: 'One compact line of context and limits.', off: 'Nothing is drawn above the prompt.' },
22}
23const line = (id: ModId, settings: UltraModSettings) =>
24 sentences[id]?.[settings.enabled ? settings.mode : 'off'] ?? null
25
26// One line under the picker: what the active set is for, in one dim sentence.
27const setNotes: Record<UltraSetName, string> = {
28 essentials: 'Everyday work. Asks before risky commands, shows receipts.',
29 strict: 'Production code. A receipt every turn, env dumps blocked.',
30 flow: 'Fewest interruptions. Compact HUD, receipts on issues.',
31 marathon: 'Long unattended runs. Refuses risky commands, auto-compacts.',
32 quiet: 'Safety only, nothing drawn. Guard, secrets and pins only.',
33}
34
35export async function renderPane(api: UltraApi, e: Frozen<RenderInput<'Pane'>>, sets: SetsEngine, mods: readonly UltraMod[], sync: () => Promise<void>) {
36 const set = await sets.current(api)
37 const { Box, Text, Button } = api.ui.resolve(e)
38 const columns = Math.max(0, Math.floor(e.props.bodyColumns))
39 const pick = async (name: string) => {
40 const selected = setNames.find(one => one === name)
41 if (selected) { await sets.switch(api, selected); await sync() }
42 }
43 const picker = (
44 <Box key="set-picker" flexDirection="row" flexWrap="wrap" gap={1}>
45 {setNames.map((name, index) => (
46 <Button key={`set-${name}`} plain label={name} hotkey={String(index + 1)} dimColor={name !== set.name} onPress={() => pick(name)} />
47 ))}
48 </Box>
49 )
50 // A row keeps its name cell, its state and one sentence; the sentence is
51 // dropped whole when the pane is too narrow to show it whole.
52 const grid = modIds.map(id => {
53 const settings = set.mods[id]
54 const state = settings.enabled ? 'on' : 'off'
55 const text = line(id, settings)
56 const used = 10 + 1 + state.length + 4 + 1
57 return <Box key={`mod-${id}`} flexDirection="row" gap={1}>
58 <Box key={`name-${id}`} width={10}><Text>{id}</Text></Box>
59 <Button key={`toggle-${id}`} label={state} onPress={async () => { await sets.toggle(api, id); await sync() }} />
60 {text !== null && used + text.length <= columns ? <Text dimColor>{text}</Text> : null}
61 </Box>
62 })
63 const sections: RenderNode[] = []
64 for (const mod of mods) {
65 if (!set.mods[mod.id].enabled || !mod.pane) continue
66 try {
67 const section = await mod.pane({ api, e, set, settings: set.mods[mod.id] })
68 if (section !== null) sections.push(section)
69 } catch { /* A section cannot prevent opening the controls. */ }
70 }
71 const hud = set.mods.hud.enabled
72 ? <Box key="hud-row" flexDirection="row" width={columns} flexWrap="nowrap">{(await hudRow(api, e, set, columns)).nodes}</Box>
73 : null
74 // The footer names the keys the open pane really holds: Tab walks the rows,
75 // Enter presses the focused button, digits 1-5 press the picker, Escape closes.
76 return <Box flexDirection="column">
77 <Text bold>{`Ultra Mod · ${setLabel(set)}`}</Text>
78 {picker}
79 <Text dimColor>{setNotes[set.name]}</Text>
80 <Box key="mod-grid" flexDirection="column">{grid}</Box>
81 {hud}
82 {sections}
83 <Text dimColor>Tab moves · Enter toggles · 1-5 switch set · Esc closes</Text>
84 </Box>
85}
86hooks/core/sets.ts 114 lines1import type { UltraApi } from './api'
2import { atom, read, update } from 'claude-code'
3import type { PluginOptions } from 'claude-code'
4import type { UltraModSettings, UltraOverrides, UltraSet, UltraSetName } from '../../types/index'
5import type { ModId } from './mod'
6
7export const setNames = ['essentials', 'strict', 'flow', 'marathon', 'quiet'] as const
8export const modIds: readonly ModId[] = ['guard', 'secrets', 'tests', 'tidy', 'loops', 'receipts', 'compact', 'notify', 'pins', 'hud']
9export const activeSet = atom({ plugin: 'ultramod', key: 'set' } as const, null)
10const setting = (mode: string, extra: Omit<UltraModSettings, 'enabled' | 'mode'> = {}): UltraModSettings => ({ enabled: mode !== 'off', mode, ...extra })
11const essentials = {
12 hud: setting('full'), receipts: setting('tools'), guard: setting('ask'), secrets: setting('on'),
13 tests: setting('ask'), notify: setting('on', { chime: true }), compact: setting('warn+offer', { warnAt: 70, offerAt: 85 }),
14 loops: setting('nudge'), pins: setting('if file'), tidy: setting('off'),
15}
16export const sets: Readonly<Record<UltraSetName, Readonly<Record<ModId, UltraModSettings>>>> = {
17 essentials,
18 strict: { ...essentials, receipts: setting('always'), guard: setting('ask', { strict: true }), secrets: setting('strict'), notify: setting('on', { chime: false }), tidy: setting('ask') },
19 flow: { ...essentials, hud: setting('compact'), receipts: setting('issues'), tests: setting('off'), notify: setting('on', { chime: false }), compact: setting('warn', { warnAt: 70 }), loops: setting('warn') },
20 marathon: { ...essentials, guard: setting('deny'), tests: setting('deny'), notify: setting('on', { chime: false }), compact: setting('auto', { warnAt: 70, offerAt: 85, autoAt: 88 }), tidy: setting('deny') },
21 quiet: { ...essentials, hud: setting('off'), receipts: setting('off'), tests: setting('off'), notify: setting('off'), compact: setting('off'), loops: setting('off') },
22}
23export const isSetName = (name: unknown): name is UltraSetName => typeof name === 'string' && setNames.some(one => one === name)
24export const projectKey = (root: string) => `project:${root}`
25export const setLabel = (set: UltraSet) => `${set.name}${Object.keys(set.overrides).length ? '*' : ''}`
26
27export function resolveSet(project: unknown, options: PluginOptions = {}): UltraSet {
28 const saved = project && typeof project === 'object' ? project as { set?: unknown; overrides?: unknown } : {}
29 const name = isSetName(saved.set) ? saved.set : isSetName(options.set) ? options.set : 'essentials'
30 const overrides: UltraOverrides = {}
31 if (saved.overrides && typeof saved.overrides === 'object') {
32 for (const id of modIds) {
33 const value = (saved.overrides as Record<string, unknown>)[id]
34 if (typeof value === 'boolean') overrides[id] = value
35 }
36 }
37 const mods = Object.fromEntries(modIds.map(id => {
38 const preset = sets[name][id]
39 const settings = overrides[id] === true && !preset.enabled ? (essentials[id].enabled ? essentials[id] : sets.strict[id]) : preset
40 return [id, { ...settings, enabled: overrides[id] ?? preset.enabled }]
41 })) as UltraSet['mods']
42 mods.notify.notifyAfterSeconds = typeof options.notifyAfterSeconds === 'number' ? options.notifyAfterSeconds : 30
43 mods.notify.sound = options.sound !== false
44 return { name, overrides, mods }
45}
46
47async function afterChanges(changes: Promise<unknown>, action: () => Promise<UltraSet>): Promise<UltraSet> {
48 try {
49 await changes
50 } catch {
51 // The queue moves on after a failed action.
52 }
53 return action()
54}
55
56async function settled(result: Promise<unknown>): Promise<void> {
57 try {
58 await result
59 } catch {
60 // A failed action does not hold up the next one.
61 }
62}
63
64export function createSets(options: PluginOptions = {}) {
65 let cache: UltraSet | null = null
66 let changes: Promise<unknown> = Promise.resolve()
67 // Serialize presses so each toggle reads the previous action's result.
68 const mutate = (action: () => Promise<UltraSet>) => {
69 const result = afterChanges(changes, action)
70 changes = settled(result)
71 return result
72 }
73 const load = async (api: UltraApi) => resolveSet(await api.store.get(projectKey(await api.session.root())), options)
74 const publish = async (api: UltraApi, set: UltraSet) => {
75 await update(api, activeSet, () => set)
76 cache = set
77 return set
78 }
79 const current = async (api: UltraApi) => {
80 const state = await read(api, activeSet)
81 cache = state ?? cache ?? await load(api)
82 return cache
83 }
84 const save = async (api: UltraApi, set: UltraSet) => {
85 await api.store.set(projectKey(await api.session.root()), { set: set.name, overrides: set.overrides })
86 return publish(api, set)
87 }
88 return {
89 current,
90 ensure: async (api: UltraApi) => {
91 const state = await read(api, activeSet)
92 return state ?? publish(api, await current(api))
93 },
94 hydrate: async (api: UltraApi) => publish(api, await load(api)),
95 enabled: async (api: UltraApi, id: ModId) => (await current(api)).mods[id].enabled,
96 switch: (api: UltraApi, name: UltraSetName) => mutate(() => save(api, resolveSet({ set: name }, options))),
97 toggle: (api: UltraApi, id: ModId) => mutate(async () => {
98 const set = await current(api)
99 const overrides = { ...set.overrides, [id]: !set.mods[id].enabled }
100 if (overrides[id] === sets[set.name][id].enabled) delete overrides[id]
101 return save(api, resolveSet({ set: set.name, overrides }, options))
102 }),
103 reset: (api: UltraApi) => mutate(async () => {
104 const set = await current(api)
105 return save(api, resolveSet({ set: set.name }, options))
106 }),
107 }
108}
109export type SetsEngine = ReturnType<typeof createSets>
110
111export async function settingsFor(api: UltraApi, id: ModId): Promise<UltraModSettings> {
112 return (await read(api, activeSet) ?? resolveSet(undefined)).mods[id]
113}
114hooks/mods/hud.tsx 174 lines1import type { UltraApi } from '../core/api'
2import { atom, read, update } from 'claude-code'
3import type { Args, Frozen, RenderInput, RenderNode, ThemeKey, Timer } from 'claude-code'
4import type { UltraSet } from '../../types/index'
5import { fmtCountdown, fmtDuration, fmtModel, fmtTokens, fmtUsd } from '../core/format'
6import type { UltraMod } from '../core/mod'
7import { setLabel } from '../core/sets'
8import type { SetsEngine } from '../core/sets'
9
10export const turn = atom({ plugin: 'ultramod', key: 'turn' } as const, {
11 id: null, startedAt: null, now: 0, durationMs: null, edits: 0, commands: 0,
12})
13
14export type HudMod = UltraMod & {
15 start: (api: UltraApi, e: Frozen<Args<'turn.start'>>) => Promise<void>
16 complete: (api: UltraApi, e: Frozen<Args<'turn.complete'>>) => Promise<void>
17 sync: (api: UltraApi) => Promise<void>
18 stop: () => void
19}
20
21// The row of segments the band draws, and the pane draws too: context, the
22// rate limits, the turn, the cost, the model and the set badge, whole segments
23// only, each dropped when the next one will no longer fit.
24export async function hudRow(api: UltraApi, e: Frozen<RenderInput<'AbovePrompt' | 'Pane'>>, set: UltraSet, columns: number) {
25 const usage = await api.session.usage()
26 const held = await read(api, turn)
27 const now = await api.clock.now()
28 const { Text } = api.ui.resolve(e)
29 const percent = usage.context.percent
30 const contextColor: ThemeKey = percent === undefined || percent < 60 ? 'text' : percent < 80 ? 'warning' : 'error'
31 const filled = percent === undefined ? 0 : Math.max(0, Math.min(10, Math.round(percent / 10)))
32 const bar = `${'█'.repeat(filled)}${'░'.repeat(10 - filled)}`
33 const contextText = percent === undefined ? `ctx ${bar} ?/${fmtTokens(usage.context.window)}` :
34 `ctx ${bar} ${percent}% ${usage.context.tokens === undefined ? '?' : fmtTokens(usage.context.tokens)}/${fmtTokens(usage.context.window)}`
35 // Nothing has answered yet, so the row starts at the limits instead of a bar of shadows.
36 const hasContext = percent !== undefined || usage.context.tokens !== undefined
37 const tail: { text: string; color?: ThemeKey; dim?: boolean }[] = []
38 const limits = usage.rateLimits.map(limit => {
39 const label = limit.kind === 'five_hour' ? '5h' : limit.kind === 'seven_day' ? '7d' : limit.kind
40 const reset = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
41 const short = `${label} ${limit.percentUsed}%`
42 return { short, full: `${short}${Number.isFinite(reset) ? ` resets ${fmtCountdown(reset - now)}` : ''}`,
43 color: (limit.percentUsed < 75 ? 'text' : limit.percentUsed < 90 ? 'warning' : 'error') as ThemeKey }
44 })
45 if (set.mods.hud.mode !== 'compact') {
46 if (held.id && held.startedAt !== null) {
47 // A fresh turn carries the timer alone; the counts join it as they grow.
48 const work = [fmtDuration(now - held.startedAt)]
49 if (held.edits > 0) work.push(`${held.edits} ${held.edits === 1 ? 'edit' : 'edits'}`)
50 if (held.commands > 0) work.push(`${held.commands} ${held.commands === 1 ? 'cmd' : 'cmds'}`)
51 tail.push({ text: work.join(' ') })
52 } else if (held.durationMs !== null) {
53 tail.push({ text: fmtDuration(held.durationMs) })
54 }
55 // A total that prints $0.00 says nothing, so it stays off the row.
56 const cost = usage.cost && usage.cost.usd > 0 ? fmtUsd(usage.cost.usd) : ''
57 if (cost && cost !== '$0.00') tail.push({ text: cost })
58 tail.push({ text: fmtModel(await api.session.model()) })
59 tail.push({ text: `ultra:${setLabel(set)}`, dim: true })
60 }
61 const build = (verbose: boolean): { text: string; color?: ThemeKey; dim?: boolean }[] => [
62 ...(hasContext ? [{ text: contextText, color: contextColor }] : []),
63 ...limits.map(limit => ({ text: verbose ? limit.full : limit.short, color: limit.color })),
64 ...tail,
65 ]
66 // The long form earns its place only while every segment still fits:
67 // both limits go short before the row drops anything after them.
68 const fits = (list: readonly { text: string }[]) =>
69 list.reduce((used, part, index) => used + (index ? 3 : 0) + part.text.length, 0) <= columns
70 const parts = build(fits(build(true)))
71 const nodes: RenderNode[] = []
72 let used = 0
73 for (const part of parts) {
74 const gap = nodes.length ? 3 : 0
75 if (used + gap + part.text.length > columns) break
76 if (gap) nodes.push(<Text dimColor> · </Text>)
77 nodes.push(<Text color={part.color} dimColor={part.dim}>{part.text}</Text>)
78 used += gap + part.text.length
79 }
80 return { nodes, contextColor, used }
81}
82
83// The band above the prompt: the HUD row, then a second row for contributions
84// with no room beside it. Null when nothing is drawn here.
85export async function bandRows(api: UltraApi, e: Frozen<RenderInput<'AbovePrompt'>>, sets: SetsEngine, mods: readonly UltraMod[]): Promise<RenderNode[] | null> {
86 if (e.surface !== 'terminal' && e.surface !== 'desktop' || e.props.hasSurvey) return null
87 if (!(await sets.enabled(api, 'hud'))) return null
88 const set = await sets.current(api)
89 const { Box, Text } = api.ui.resolve(e)
90 const columns = Math.max(0, Math.floor(e.props.bodyColumns))
91 const { nodes: row, contextColor, used: consumed } = await hudRow(api, e, set, columns)
92 let used = consumed
93 // The readouts fill their row first, so a contribution with no room beside
94 // them (Compact now, 15 cells) takes a row of its own instead of vanishing.
95 const second: RenderNode[] = []
96 let usedSecond = 0
97 for (const mod of mods) {
98 if (!mod.band || !set.mods[mod.id].enabled) continue
99 const gap = row.length ? 3 : 0
100 const remaining = Math.max(0, columns - used - gap)
101 try {
102 const part = await mod.band({ api, e, set, settings: set.mods[mod.id], columns: remaining, contextColor })
103 if (!part || part.columns <= 0) continue
104 if (part.columns <= remaining) {
105 if (gap) row.push(<Text dimColor> · </Text>)
106 row.push(part.node)
107 used += gap + part.columns
108 continue
109 }
110 const secondGap = second.length ? 3 : 0
111 if (usedSecond + secondGap + part.columns > columns) continue
112 if (secondGap) second.push(<Text dimColor> · </Text>)
113 second.push(part.node)
114 usedSecond += secondGap + part.columns
115 } catch {
116 // A broken contribution must leave the other rows visible.
117 }
118 }
119 const rows = [<Box flexDirection="row" width={columns} flexWrap="nowrap">{row}</Box>]
120 if (second.length) rows.push(<Box flexDirection="row" width={columns} flexWrap="nowrap">{second}</Box>)
121 return rows
122}
123
124export function createHud(sets: SetsEngine): HudMod {
125 let timer: Timer | null = null
126 const stop = () => {
127 timer?.cancel()
128 timer = null
129 }
130 const tick = async (api: UltraApi) => {
131 if (!(await sets.enabled(api, 'hud')) || !(await read(api, turn)).id) {
132 stop()
133 return
134 }
135 const now = await api.clock.now()
136 await update(api, turn, held => held.id ? { ...held, now } : held)
137 }
138 const sync = async (api: UltraApi) => {
139 if (!(await sets.enabled(api, 'hud')) || !(await read(api, turn)).id) {
140 stop()
141 } else if (!timer) {
142 timer = api.clock.every(1_000, () => { void tick(api).catch(stop) })
143 }
144 }
145 const start: HudMod['start'] = async (api, e) => {
146 stop()
147 const now = await api.clock.now()
148 await update(api, turn, () => ({ id: e.turnId, startedAt: now, now, durationMs: null, edits: 0, commands: 0 }))
149 await sync(api)
150 }
151 const complete: HudMod['complete'] = async (api, e) => {
152 if (e.agentId) return
153 stop()
154 await update(api, turn, held => ({ ...held, id: null, startedAt: null, durationMs: e.durationMs }))
155 }
156
157 return {
158 id: 'hud', start, complete, sync, stop,
159 watch: {
160 when: e => !e.agentId && ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'].includes(String(e.tool)),
161 run: (api, e) => async result => {
162 if (!result.deny && (String(e.tool) === 'Bash' || !result.isError)) {
163 await update(api, turn, held => held.id ? {
164 ...held,
165 edits: held.edits + (String(e.tool) === 'Bash' ? 0 : 1),
166 commands: held.commands + (String(e.tool) === 'Bash' ? 1 : 0),
167 } : held)
168 }
169 return result
170 },
171 },
172 }
173}
174hooks/mods/index.ts 20 lines1import type { UltraMod } from '../core/mod'
2import type { SetsEngine } from '../core/sets'
3import { guard } from './guard'
4import { secrets } from './secrets'
5import { tests } from './tests'
6import { tidy } from './tidy'
7import { loops } from './loops'
8import { receipts } from './receipts'
9import { compact } from './compact'
10import { notify } from './notify'
11import { pins } from './pins'
12import { createHud } from './hud'
13
14export function createMods(sets: SetsEngine) {
15 const mods: UltraMod[] = [guard, secrets, tests, tidy, loops, receipts, compact, notify, pins]
16 const hud = createHud(sets)
17 mods.push(hud)
18 return { mods, hud }
19}
20hooks/core/notifier.ts 223 lines1// Shared notification sender. The notify mod and the guard and tests ask
2// paths send through one platform detection, one rate limit and one chime,
3// so an away user hears about every dialog that holds the turn.
4import type { UltraApi } from './api'
5import { resolveSet, settingsFor } from './sets'
6import type { UltraModSettings } from '../../types/index'
7
8type Notifier = 'notify-send' | 'osascript' | 'powershell' | 'toast'
9
10// Detected once per session; a failed detection falls back to a toast.
11let detection: Promise<Notifier> | null = null
12let lastSent = -1_000_000
13let chime: string | null = null
14
15// Test seam: detection and the rate window otherwise live per session.
16export function resetNotifier(): void {
17 detection = null
18 lastSent = -1_000_000
19 chime = null
20}
21
22export async function notifierSettings(api: UltraApi): Promise<UltraModSettings> {
23 try {
24 return await settingsFor(api, 'notify')
25 } catch {
26 return resolveSet(undefined).mods.notify
27 }
28}
29
30// The notifier a send would use, asked fresh; /ultra doctor reports it.
31export async function detectNotifier(api: UltraApi): Promise<Notifier> {
32 let os: string | undefined
33 let wsl: string | undefined
34 try {
35 os = await api.env.get('OS')
36 wsl = await api.env.get('WSL_DISTRO_NAME')
37 } catch {
38 os = undefined
39 }
40 const has = async (name: string) => {
41 try {
42 const result = await api.process.run(['which', name])
43 return result.exitCode === 0
44 } catch {
45 return false
46 }
47 }
48 if (os === 'Windows_NT' || (wsl !== undefined && wsl !== '')) return 'powershell'
49 // OS is only ever set on Windows, so the platform comes from the kernel.
50 if ((await kernel(api)) === 'Darwin') return (await has('osascript')) ? 'osascript' : 'toast'
51 return (await has('notify-send')) ? 'notify-send' : 'toast'
52}
53
54// `uname -s`: Darwin, Linux, and so on. Empty when it cannot be asked.
55async function kernel(api: UltraApi): Promise<string> {
56 try {
57 const result = await api.process.run(['uname', '-s'])
58 return result.exitCode === 0 ? result.stdout.trim() : ''
59 } catch {
60 return ''
61 }
62}
63
64// Quote-safe argv forms: no shell strings and no inline programs. Both
65// platform notifiers run a script shipped in the plugin's scripts folder and
66// receive the text as data. AppleScript gets title and body as arguments, so a
67// quote or a newline in them is never script source. PowerShell gets the body
68// as base64: it treats curly quotes as delimiters, so no escaping of the text
69// is safe.
70export function scriptPath(root: string, file: string): string {
71 const sep = root.includes('\\') ? '\\' : '/'
72 return `${root.replace(/[\\/]+$/, '')}${sep}scripts${sep}${file}`
73}
74
75// powershell.exe under WSL reads Windows paths, so the script path goes
76// through wslpath. A failed conversion throws and the toast takes over.
77async function powerShellScript(api: UltraApi): Promise<string> {
78 const script = scriptPath(api.plugin.root, 'notify.ps1')
79 const [os, wsl] = await Promise.all([api.env.get('OS'), api.env.get('WSL_DISTRO_NAME')])
80 if (os === 'Windows_NT' || wsl === undefined || wsl === '') return script
81 const result = await api.process.run(['wslpath', '-w', script])
82 const converted = result.stdout.trim()
83 if (result.exitCode !== 0 || converted === '') throw new Error('wslpath could not convert the notifier script path')
84 return converted
85}
86
87function utf8(text: string): number[] {
88 const bytes: number[] = []
89 for (const ch of text) {
90 let code = ch.codePointAt(0) ?? 0xfffd
91 // A lone surrogate has no UTF-8 form.
92 if (code >= 0xd800 && code <= 0xdfff) code = 0xfffd
93 if (code < 0x80) bytes.push(code)
94 else if (code < 0x800) bytes.push(0xc0 | code >> 6, 0x80 | code & 0x3f)
95 else if (code < 0x10000) bytes.push(0xe0 | code >> 12, 0x80 | code >> 6 & 0x3f, 0x80 | code & 0x3f)
96 else bytes.push(0xf0 | code >> 18, 0x80 | code >> 12 & 0x3f, 0x80 | code >> 6 & 0x3f, 0x80 | code & 0x3f)
97 }
98 return bytes
99}
100
101const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
102
103function base64(bytes: readonly number[]): string {
104 let out = ''
105 for (let i = 0; i < bytes.length; i += 3) {
106 const a = bytes[i] ?? 0
107 const b = bytes[i + 1] ?? 0
108 const c = bytes[i + 2] ?? 0
109 out += B64[a >> 2] ?? ''
110 out += B64[(a & 3) << 4 | b >> 4] ?? ''
111 out += i + 1 < bytes.length ? B64[(b & 15) << 2 | c >> 6] ?? '' : '='
112 out += i + 2 < bytes.length ? B64[c & 63] ?? '' : '='
113 }
114 return out
115}
116
117// The chime is built here instead of shipped as a file: a binary asset does
118// not survive an installer that copies files as text. Two soft notes, the
119// second a fifth above the first, each fading out over about a second.
120function chimeClip(): { base64: string; mime: string } {
121 if (chime === null) {
122 const rate = 11_025
123 const count = rate
124 const samples: number[] = []
125 const note = (t: number, start: number, hz: number, gain: number): number => {
126 const age = t - start
127 if (age < 0) return 0
128 const attack = Math.min(1, age / 0.008)
129 const tone = Math.sin(2 * Math.PI * hz * age) + 0.15 * Math.sin(4 * Math.PI * hz * age)
130 return gain * attack * Math.exp(-age / 0.22) * tone
131 }
132 for (let i = 0; i < count; i++) {
133 const t = i / rate
134 const fade = Math.min(1, (count - i) / 200)
135 const level = (note(t, 0, 660, 0.32) + note(t, 0.1, 990, 0.24)) * fade
136 const value = Math.max(-1, Math.min(1, level)) * 32767 | 0
137 samples.push(value & 0xff, value >> 8 & 0xff)
138 }
139 const le = (value: number, size: number): number[] => Array.from({ length: size }, (_, k) => value >> 8 * k & 0xff)
140 const ascii = (text: string): number[] => Array.from(text, ch => ch.charCodeAt(0))
141 const header = [
142 ...ascii('RIFF'), ...le(36 + samples.length, 4), ...ascii('WAVEfmt '), ...le(16, 4), ...le(1, 2), ...le(1, 2),
143 ...le(rate, 4), ...le(rate * 2, 4), ...le(2, 2), ...le(16, 2), ...ascii('data'), ...le(samples.length, 4),
144 ]
145 chime = base64([...header, ...samples])
146 }
147 return { base64: chime, mime: 'audio/wav' }
148}
149
150// A notifier that exits nonzero did not show anything: let the toast take over.
151async function runNotifier(api: UltraApi, argv: string[]): Promise<void> {
152 const result = await api.process.run(argv)
153 if (result.exitCode !== 0) throw new Error(`${argv[0] ?? 'notifier'} exited with ${result.exitCode}`)
154}
155
156async function clockNow(api: UltraApi): Promise<number> {
157 try {
158 return await api.clock.now()
159 } catch {
160 return Date.now()
161 }
162}
163
164// The folder name a notification body leads with.
165export async function projectFolder(api: UltraApi): Promise<string> {
166 try {
167 const root = await api.session.root()
168 const parts = root.split(/[\\/]/).filter(part => part !== '')
169 return parts.length > 0 ? (parts[parts.length - 1] ?? root) : root
170 } catch {
171 return 'Claude Code'
172 }
173}
174
175// Returns the time the notification went out, or null when it did not send.
176export async function sendNotification(api: UltraApi, body: string, settings: UltraModSettings): Promise<number | null> {
177 const now = await clockNow(api)
178 if (now - lastSent < 10_000) return null
179 lastSent = now
180 try {
181 detection ??= detectNotifier(api)
182 const notifier = await detection.catch(() => 'toast' as Notifier)
183 if (notifier === 'notify-send') await runNotifier(api, ['notify-send', 'Claude Code', body])
184 else if (notifier === 'osascript') await runNotifier(api, ['osascript', scriptPath(api.plugin.root, 'notify.applescript'), 'Claude Code', body])
185 else if (notifier === 'powershell') await runNotifier(api, ['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', await powerShellScript(api), '-BodyBase64', base64(utf8(body))])
186 else api.ui.toast(body)
187 } catch {
188 try {
189 api.ui.toast(body)
190 } catch {
191 // Nothing left to try; a notification must never throw.
192 }
193 }
194 if (settings.chime === true && settings.sound !== false) {
195 try {
196 await api.audio.play(chimeClip())
197 } catch {
198 // A missing chime must never fail the notification.
199 }
200 }
201 return now
202}
203
204// The "needs you" notification guard and tests raise while their dialog
205// waits: title Claude Code, body "<project folder> needs you: <short
206// reason>". Started and not awaited: the caller's dialog holds its own hook
207// open, and clock.after would only run after that hook resolved, which is
208// after the user has answered. Only while the notify mod is on; the chime
209// follows the sound option.
210export function needsYou(api: UltraApi, reason: string): void {
211 void deliverNeedsYou(api, reason)
212}
213
214async function deliverNeedsYou(api: UltraApi, reason: string): Promise<void> {
215 try {
216 const settings = await notifierSettings(api)
217 if (!settings.enabled) return
218 await sendNotification(api, `${await projectFolder(api)} needs you: ${reason}`, settings)
219 } catch {
220 // The caller's own work is done by now; never throw behind it.
221 }
222}
223hooks/core/format.ts 48 lines1export function fmtTokens(tokens: number): string {
2 if (tokens < 1_000) return String(Math.round(tokens))
3 // 999,500 rounds to 1000k, so the million form starts where the thousands round up to it.
4 const thousands = Math.round(tokens / 1_000)
5 if (thousands < 1_000) return `${thousands}k`
6 return `${Number((tokens / 1_000_000).toFixed(1))}m`
7}
8
9// Elapsed time for the turn timer, receipts and finished-turn notices:
10// seconds stay visible under an hour, then two units per band.
11export function fmtDuration(ms: number): string {
12 const seconds = Math.max(0, Math.floor(ms / 1_000))
13 const days = Math.floor(seconds / 86_400)
14 const hours = Math.floor(seconds / 3_600) % 24
15 const minutes = Math.floor(seconds / 60) % 60
16 const rest = seconds % 60
17 const pad = (n: number) => String(n).padStart(2, '0')
18 if (days) return `${days}d${hours}h`
19 if (hours) return `${hours}h${pad(minutes)}m`
20 if (minutes) return `${minutes}m${pad(rest)}s`
21 return `${rest}s`
22}
23
24// Time until a rate-limit reset or since a snapshot: one unit per band under
25// a day, so an age reads short ("41m", "7h50m").
26export function fmtCountdown(ms: number): string {
27 const seconds = Math.max(0, Math.floor(ms / 1_000))
28 const days = Math.floor(seconds / 86_400)
29 const hours = Math.floor(seconds / 3_600) % 24
30 const minutes = Math.floor(seconds / 60) % 60
31 const rest = seconds % 60
32 const pad = (n: number) => String(n).padStart(2, '0')
33 if (days) return `${days}d${hours}h`
34 if (hours) return `${hours}h${pad(minutes)}m`
35 if (minutes) return `${minutes}m`
36 return `${rest}s`
37}
38
39export const fmtUsd = (usd: number) => `$${usd.toFixed(2)}`
40
41export function fmtModel(model: string): string {
42 const id = model.replace(/^claude-/, '')
43 const known = /^(opus|sonnet|haiku)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/.exec(id)
44 if (!known) return id
45 const name = known[1] ?? ''
46 return `${name[0]?.toUpperCase()}${name.slice(1)} ${known[2]}${known[3] ? `.${known[3]}` : ''}`
47}
48hooks/mods/guard.tsx 211 lines1import { atom, read, update } from 'claude-code'
2import type { UltraApi } from '../core/api'
3import { fmtCountdown } from '../core/format'
4import type { UltraMod } from '../core/mod'
5import { needsYou, scriptPath } from '../core/notifier'
6import { addAllowedRisk } from '../core/state'
7import { settingsFor } from '../core/sets'
8import { classifyCommand, runsElsewhere } from '../lib/risk'
9import { redactSecrets } from '../lib/secrets'
10import { KEEP_SNAPSHOTS, SNAPSHOT_PREFIX, SNAPSHOT_REF, restoreSnapshot, saveSnapshot } from '../lib/snapshot'
11import type { DropFile, GitRun } from '../lib/snapshot'
12import type { RiskHit } from '../lib/risk'
13
14// The validator wants every atom in a const of the file that reads and writes it.
15const allow = atom({ plugin: 'ultramod', key: 'allow' } as const, { risks: [], paths: [] })
16
17const cut = (command: string) => command.length > 120 ? `${command.slice(0, 117)}...` : command
18
19// A command quoted back to the model, a log, a notification or a commit
20// message: secrets are redacted first (before the cut, so a token is not
21// split), since this mod runs ahead of the secrets mod.
22const brief = (command: string) => cut(redactSecrets(command).text)
23
24// Safer stand-ins the deny text can name for the common discard commands.
25const TIPS: Record<string, string> = {
26 'git-reset-hard': 'git stash',
27 'git-checkout-discard': 'git stash',
28 'git-restore-discard': 'git stash',
29 'git-clean': 'git clean --dry-run',
30}
31
32const declinedText = (command: string, hit: RiskHit) =>
33 `The user declined \`${brief(command)}\`. It ${hit.reason}. Ask them before running it again${TIPS[hit.id] ? `, or use a safer command such as \`${TIPS[hit.id]}\`` : ''}.`
34
35const deniedText = (command: string, hit: RiskHit) =>
36 `Not running \`${brief(command)}\`: it ${hit.reason}. This project runs Ultra Mod's marathon set, which refuses risky commands; ask the user.`
37
38type GitResult = { ok: boolean; out: string }
39
40async function git(api: UltraApi, cwd: string, argv: string[], env?: Record<string, string>): Promise<GitResult> {
41 try {
42 const result = await api.process.run(argv, env ? { cwd, env } : { cwd })
43 return { ok: result.exitCode === 0, out: result.stdout.trim() }
44 } catch {
45 return { ok: false, out: '' }
46 }
47}
48
49// The snapshot code takes its git runner and file remover from here.
50const runner = (api: UltraApi): GitRun => (argv, cwd, env) => git(api, cwd, argv, env)
51
52// On Windows the file goes through the shipped scripts/remove-index.ps1, which
53// removes only Ultra Mod's own index files; elsewhere through `rm -f`.
54const remover = (api: UltraApi): DropFile => async (cwd, path) => {
55 let windows = false
56 try {
57 windows = await api.env.get('OS') === 'Windows_NT'
58 } catch {
59 windows = false
60 }
61 if (!windows) {
62 await git(api, cwd, ['rm', '-f', path])
63 return
64 }
65 await git(api, cwd, ['powershell.exe', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath(api.plugin.root, 'remove-index.ps1'), '-Path', path.split('/').join('\\')])
66}
67
68async function snapshot(api: UltraApi, command: string): Promise<void> {
69 const cwd = await api.session.cwd()
70 await saveSnapshot(runner(api), remover(api), cwd, `${SNAPSHOT_PREFIX}${brief(command)}`, await api.clock.now(), why => { api.ui.log(`guard snapshot skipped: ${why}`) })
71}
72
73type Snapshot = { sha: string; at: number; command: string }
74
75async function listSnapshots(api: UltraApi): Promise<Snapshot[]> {
76 const cwd = await api.session.cwd()
77 const listed = await git(api, cwd, ['git', 'for-each-ref', '--sort=-committerdate', '--sort=-refname', `--format=%(objectname)%09%(committerdate:unix)%09%(contents:subject)`, SNAPSHOT_REF])
78 if (!listed.ok) return []
79 return listed.out.split('\n').filter(Boolean).map(line => {
80 const [sha = '', at = '', ...rest] = line.split('\t')
81 const subject = rest.join('\t')
82 return { sha, at: Number(at) * 1000, command: subject.startsWith(SNAPSHOT_PREFIX) ? subject.slice(SNAPSHOT_PREFIX.length) : subject }
83 }).filter(snap => snap.sha)
84}
85
86// A risk id is lowercase words joined by hyphens (git-clean). Anything else,
87// a name with a dot or underscore such as server.pem or id_rsa, or a path, is
88// left to the secrets mod, which the null answer defers to.
89const RISK_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
90const looksLikeRiskId = (value: string) => RISK_ID.test(value)
91
92async function allowIds(api: UltraApi): Promise<string[]> {
93 return (await read(api, allow)).risks
94}
95
96export const guard: UltraMod = {
97 id: 'guard',
98 check: {
99 when: e => e.tool === 'Bash' && typeof e.command === 'string',
100 run: async (api, e) => {
101 // Narrow on the tool before reading its arguments: the envelope is a
102 // union the declarations discriminate by `tool`.
103 if (e.tool !== 'Bash' || typeof e.command !== 'string') return null
104 const command = e.command
105 const settings = await settingsFor(api, 'guard')
106 const mode = settings.mode === 'deny' || settings.mode === 'log' ? settings.mode : 'ask'
107 const hit = classifyCommand(command, { strict: settings.strict === true })
108 if (!hit) return null
109 // A snapshot saves this session's repository, which a command that
110 // moves elsewhere does not change.
111 const elsewhere = hit.snapshot && runsElsewhere(command)
112 if (!(await allowIds(api)).includes(hit.id)) {
113 if (mode === 'deny') {
114 // Marathon refuses unattended; the notification is the only voice it has.
115 needsYou(api, `guard: refused \`${brief(command)}\``)
116 return deniedText(command, hit)
117 }
118 if (mode === 'log') {
119 api.ui.log(`guard: ${hit.reason} (${brief(command)})`)
120 } else {
121 // The whole command: the answer covers all of it, so a long one is not cut.
122 const note = !hit.snapshot ? '' : elsewhere
123 ? ' It acts outside this session\'s repository, so no snapshot is saved and /ultra undo cannot restore it.'
124 : ' A work tree snapshot is saved first, so /ultra undo can restore it.'
125 const question = `Run \`${command}\`? It ${hit.reason}.${note}`
126 // An away user has to hear the dialog before it can wait for them.
127 needsYou(api, `guard: run \`${brief(command)}\`?`)
128 let answer: string
129 try {
130 answer = await api.ui.ask(question, { header: 'Ultra Mod', options: ['Run it', 'Allow for session', 'Refuse'] })
131 } catch {
132 return declinedText(command, hit)
133 }
134 if (answer === 'Run it') {
135 // fall through to the snapshot
136 } else if (answer === 'Allow for session') {
137 await update(api, allow, value => addAllowedRisk(value, hit.id))
138 } else {
139 return declinedText(command, hit)
140 }
141 // The engine may still show its own prompt after this dialog:
142 // Ultra Mod has no permission hook and never answers allow.
143 }
144 }
145 if (hit.snapshot && !elsewhere) await snapshot(api, command)
146 return null
147 },
148 },
149 commands: {
150 allow: async (api, args) => {
151 const value = args.trim()
152 if (!value) {
153 const risks = await allowIds(api)
154 return { text: risks.length ? `Allowed this session: ${risks.join(', ')}` : 'No risk ids are allowed this session. Use /ultra allow <risk id>.' }
155 }
156 if (!looksLikeRiskId(value)) return null
157 await update(api, allow, held => addAllowedRisk(held, value))
158 return { text: `${value} is allowed for this session.` }
159 },
160 undo: async (api, args) => {
161 const snapshots = await listSnapshots(api)
162 const value = args.trim()
163 if (!value) {
164 if (!snapshots.length) return { text: 'No snapshots yet. One is saved before each risky command that can be undone.' }
165 const now = await api.clock.now()
166 const lines = snapshots.slice(0, KEEP_SNAPSHOTS).map((snap, i) => `${i + 1} ${fmtCountdown(now - snap.at)} ago ${snap.command}`)
167 return { text: ['Snapshots, newest first:', ...lines].join('\n') }
168 }
169 const index = Number(value)
170 if (!Number.isInteger(index) || index < 1 || index > snapshots.length) {
171 return { text: `Choose a snapshot number from /ultra undo (1 to ${snapshots.length}).` }
172 }
173 const snap = snapshots[index - 1]
174 if (!snap) return { text: 'Choose a snapshot number from /ultra undo.' }
175 let answer = 'Cancel'
176 try {
177 answer = await api.ui.ask(`Restore snapshot ${index} (${snap.command})? Files in the snapshot go back to their saved content, so changes made to them since are lost. Files created after it are kept.`, { header: 'Ultra Mod', options: ['Restore', 'Cancel'] })
178 } catch {
179 // A dismissed question restores nothing.
180 }
181 if (answer !== 'Restore') return { text: `Snapshot ${index} was not restored.` }
182 const cwd = await api.session.cwd()
183 const root = await git(api, cwd, ['git', 'rev-parse', '--show-toplevel'])
184 const target = root.ok && root.out ? root.out : cwd
185 let conflicts: string[] = []
186 const restored = await restoreSnapshot(runner(api), remover(api), target, snap.sha, paths => { conflicts = paths })
187 if (conflicts.length) {
188 const shown = conflicts.slice(0, 5).join(', ')
189 const more = conflicts.length > 5 ? ` and ${conflicts.length - 5} more` : ''
190 return { text: `Snapshot ${index} was not restored: ${shown}${more} ${conflicts.length === 1 ? 'is' : 'are'} a file where the snapshot has a directory, or a directory where it has a file, and writing it back would delete newer work. Move ${conflicts.length === 1 ? 'it' : 'them'} aside and run /ultra undo ${index} again.` }
191 }
192 if (!restored) return { text: `Could not restore snapshot ${index}: git could not finish writing the files. Check git status.` }
193 const files = await git(api, target, ['git', 'ls-tree', '-r', '--name-only', '--full-tree', snap.sha])
194 const names = files.ok ? files.out.split('\n').filter(Boolean) : []
195 const shown = names.slice(0, 5).join(', ')
196 const more = names.length > 5 ? ` and ${names.length - 5} more` : ''
197 return { text: `Restored snapshot ${index}: ${names.length} ${names.length === 1 ? 'file' : 'files'} (${shown}${more}). Files created after it were kept.` }
198 },
199 },
200 pane: async ({ api, e }) => {
201 const snapshots = (await listSnapshots(api)).slice(0, 5)
202 if (!snapshots.length) return null
203 const { Box, Text } = api.ui.resolve(e)
204 const now = await api.clock.now()
205 return <Box key="snapshots" flexDirection="column">
206 <Text bold>Snapshots</Text>
207 {snapshots.map(snap => <Text key={`snapshot-${snap.sha}`}>{`${fmtCountdown(now - snap.at)} ago ${snap.command}`}</Text>)}
208 </Box>
209 },
210}
211