SLOPSHOPPER

ultramod

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…

newpanebandguardcommandtoast
★ 2v1.0.5MITupdated 2026-10-09mertkayacs/ultramod/plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ultramod
│ ┃ Ultra Mod ✕ › fix the failing auth test and add an audit log call │ ┃ Ultra Mod · essentials │ ┃ 1: essentials 2: strict 3: flow 4: marathon ⏺ Read(src/auth.ts) │ ┃ Everyday work. Asks before risky commands, ⎿ Read 6 lines │ ┃ shows receipts. ⏺ Update(src/auth.ts) │ ┃ guard [ on ] Asks before risky commands ⎿ Added 2 lines, removed 1 line │ ┃ secrets [ on ] Blocks secret files and re ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ tests [ on ] Asks before an edit weaken ⎿ Denied by ultramod: The user declined `rm -rf build && gi │ ┃ tidy [ off ] New documentation files a │ ┃ loops [ on ] Stops retry loops with a s ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ receipts [ on ] │ ┃ compact [ on ] Warns at 70% and offers to ✻ Worked for 42s · done 4:20 PM │ ┃ notify [ on ] Notices when a turn finish receipt · 3 files · 2 cmds (1 failed) · tests failed · 42s │ ┃ pins [ on ] │ ┃ hud [ on ] Shows context, limits, tur › /ultra │ ┃ ctx █████░░░░░ 49% 97k/200k · 5h 31% · 42s · ⎿ ultramod: Controls opened. │ ┃ Last receipts │ ┃ 0s ago receipt · 3 files · 2 cmds (1 │ ┃ failed) · tests failed · 42s │ ┃ Tab moves · Enter toggles · 1-5 switch set · │ ┃ Esc closes │ ctx █████░░░░░ 49% 97k/200k · 5h 31% · 42s · $0.42 · Opus 5.5 · ultra:essentials ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx █████░░░░░ 49% 97k/200k · 5h 31% · 42s · $0.42 · Opus 5.5 · ultra:essentials ⟨Claude Code's own drawing⟩
Pane · Ultra Mod
Ultra Mod · essentials 1: essentials 2: strict 3: flow 4: marathon 5: quiet Everyday work. Asks before risky commands, shows receipts. guard [ on ] Asks before risky commands run. secrets [ on ] Blocks secret files and redacts leaks. tests [ on ] Asks before an edit weakens tests. tidy [ off ] New documentation files are allowed. loops [ on ] Stops retry loops with a suggestion. receipts [ on ] compact [ on ] Warns at 70% and offers to compact. notify [ on ] Notices when a turn finishes or waits. pins [ on ] hud [ on ] Shows context, limits, turns and cost. ctx █████░░░░░ 49% 97k/200k · 5h 31% · 42s · $0.42 Last receipts 0s ago receipt · 3 files · 2 cmds (1 failed) · tests failed · 42s Tab moves · Enter toggles · 1-5 switch set · Esc closes
README

Ultra Mod

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.

The mods

  • hud: one line above the prompt with the context bar, the 5-hour and 7-day limits with reset times, the turn timer, cost and model.
  • receipts: a line under each answer with the files changed, commands run, tests passed or failed, time and cost. It flags a "tests pass" claim when no passing test ran after the last edit.
  • guard: holds 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.
  • secrets: refuses reads of .env, private keys and credential files, and masks known token formats in tool output before Claude sees it.
  • tests: asks before Claude skips a test, adds .only, deletes a test file or removes assertions.
  • notify: a desktop notification when a long turn ends or Claude is waiting for you.
  • compact: warns at 70% context and offers a one-key compaction at 85%.
  • loops: notices the same command failing three times and tells Claude to stop and rethink.
  • pins: keeps the rules in .claude/pins.md in the system prompt for the whole session.
  • tidy: asks before Claude writes summary or notes files you did not ask for (strict set).

Five sets switch everything at once: essentials (default), strict, flow, marathon and quiet. /ultra set strict saves the choice for the current project.

What each hook decides, and when

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.

HookWhen it runsWhat it decides
session.starta session startsnothing: starts the HUD over, reads the project's set and registers /ultra, then passes the event on unchanged
classic.SessionStarta conversation is cleared, resumed or forkednothing: the same as above, and clears this session's receipts and context warnings, then passes the event on unchanged
turn.starta turn startsnothing: starts the turn timer and the receipt, then passes the event on unchanged
turn.completea turn endswhether a receipt line goes under the answer (receipts), whether to show a context warning (compact), whether to send a "finished" notification (notify)
session.enda session endsnothing: stops the HUD timer, then passes the event on unchanged
command.run on /ultrayou 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 paneClaude Code draws those two placeswhat 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 Globbefore one of those tool calls runswhether 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 toolaround a call the check let throughnothing 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.appenda tool result is about to be stored in the conversationwhether the result holds a known token format to mask (secrets)
prompt.composeClaude Code builds the system promptwhether .claude/pins.md has rules to add (pins)
classic.NotificationClaude Code says it is waiting for youwhether 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.

What the hooks change

  • A refused tool call: Claude reads the refusal reason, for example "The user declined git reset --hard. It discards uncommitted changes."
  • loops: after the same Bash command fails three times with the same error, or Edit misses 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.
  • secrets: in a tool result about to be stored, known token formats (GitHub, Anthropic, OpenAI, AWS, Stripe and others) become a marker such as [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.
  • receipts: one line under the answer, after any line a plugin beneath already added.
  • pins: one section with the lines of .claude/pins.md added after the other system prompt sections.
  • hud and compact: rows drawn above the prompt, above what other plugins draw there. compact can add a Compact now button.
  • /ultra: the control pane and the text /ultra prints.

What it sends, and where

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.

Hosts it contacts

None.

Commands it runs, and why

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.
  • It never pushes, fetches, commits on a branch, or changes your branch, your index or your history.
  • 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.
  • The notifier, when a long turn ends, when Claude Code waits for you, or while guard or tests holds a call for your answer:
  • Linux: notify-send "Claude Code" <message>
  • macOS: osascript <plugin>/scripts/notify.applescript "Claude Code" <message>
  • Windows and WSL: 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.

Files it writes

  • .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.
  • Git refs under refs/worktree/ultramod/snapshots/ in the repository you work in, when guard saves a snapshot before a risky command it lets through.
  • The temporary index files ultramod-index and ultramod-restore-index in that repository's git directory, removed right after use.
  • The files of a snapshot, written back into your work tree, only when you run /ultra undo <n> and confirm.
  • Its own state (the chosen set, this session's allowances, receipt history, the HUD's turn counters) in Claude Code's plugin storage.

What it reads on your machine

  • Environment variables: 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.
  • Files: .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.
  • Tool calls and tool results inside the Claude Code session, to decide what to hold, count or mask.

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.

Source 29 files
hooks/register.tsx 214 lines
1import 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}
214
hooks/core/api.ts 20 lines
1import 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}
20
hooks/core/runtime.ts 253 lines
1import 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}
253
hooks/core/mod.ts 70 lines
1import 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}
70
hooks/core/commands.ts 61 lines
1import 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}
61
hooks/core/pane.tsx 86 lines
1import 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}
86
hooks/core/sets.ts 114 lines
1import 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}
114
hooks/mods/hud.tsx 174 lines
1import 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}
174
hooks/mods/index.ts 20 lines
1import 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}
20
hooks/core/notifier.ts 223 lines
1// 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}
223
hooks/core/format.ts 48 lines
1export 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}
48
hooks/mods/guard.tsx 211 lines
1import { 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