SLOPSHOPPER

mod-pack

A pack of small Claude Code mods that share the band above the prompt without overwriting each other. Turn each one on or off with /mods.

newpanebandguardcommandtoast
v0.1.0MITupdated 2026-10-04az9713/claude-mod-pack/mod-pack
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-pack
│ ┃ Snake ✕ › fix the failing auth test and add an audit log call │ ┃ ⟨Claude Code's own drawing⟩ │ ┃ Snake score 0 best 0 ⏺ Read(src/auth.ts) │ ┃ PAUSED – press p to play ⎿ Read 6 lines │ ┃ · · · · · · · · · · · · · · · · ⏺ Update(src/auth.ts) │ ┃ · · · · · · · · · · · · · · · · ⎿ Added 2 lines, removed 1 line │ ┃ · · · · · · · · · · · · · · · · ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ · · · · · · ● · · · · · · · · · ⎿ Denied by mod-pack: Blast Radius: the person cancelled th │ ┃ · · · ██████· · · · · · · · · · │ ┃ · · · · · · · · · · · · · · · · ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ · · · · · · · · · · · · · · · · │ ┃ · · · · · · · · · · · · · · · · ✻ Worked for 42s · done 4:20 PM │ ┃ w: up a: left s: down d: right │ ┃ p: play r: restart › /mods │ ┃ Keys need the pane focused: click it, or ⎿ mod-pack: mod-pack features (sound: OFF): │ ┃ ctrl+x then Tab. ⎿ mod-pack: ON token-weather Token Weather: context-window fo │ ⎿ mod-pack: ON cache-keeper Cache Keeper: countdown of how lo │ ⎿ mod-pack: ON prompt-queue Prompt Queue: type /q <text> whil │ ⎿ mod-pack: OFF wait-what Wait What: plain-words retell of eac │ ⎿ mod-pack: ON blast-radius Blast Radius: asks Proceed or Can │ │ ⟨Claude Code's own drawing⟩ ☁ Cloudy 49% 97.4k / 200k ▄ ▲ +97.4k last turn ⏱ cache warm 59m left · 97.4k tokens [ compact ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ☁ Cloudy 49% 97.4k / 200k ▄ ▲ +97.4k last turn ⏱ cache warm 59m left · 97.4k tokens [ compact ]
Pane · Snake
⟨Claude Code's own drawing⟩ Snake score 0 best 0 PAUSED – press p to play · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · ● · · · · · · · · · · · · ██████· · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · · w: up a: left s: down d: right p: play r: restart Keys need the pane focused: click it, or ctrl+x then Tab.
README

mod-pack

A pack of small Claude Code mods in one plugin. Token Weather, Cache Keeper, Prompt Queue and Wait What draw in the band above the prompt. The mods that draw there share that band without overwriting each other, or other plugins. Blast Radius and Ship Gate draw nothing: they ask a question, and Ship Gate can also deny a commit. Snake draws a pane of its own, not a band row. /mods turns each one on or off. Wait What is the one mod that spends model tokens, and it is off by default. Prompt Queue is the one mod that sends prompts by itself: read its security note.

Status: version 0.1.0. Seven mods exist. The tests and claude plugin validate pass. Screenshots of a real terminal show Token Weather, Cache Keeper, Wait What and next-steps drawn together, the Blast Radius dialog, and the Snake pane. Ship Gate has been run only against a stubbed engine, not on a real screen. No person has yet pressed the Cache Keeper button, typed /q while a turn ran, or played Snake to a score. See "What is verified".

Requires Claude Code 2.1.287 or later. Mods are plugins of "function hooks", an early-access API that can change between releases.

Mods

ModStatusWhat it does
Token Weather (token-weather)availableOne row above the prompt: a weather word for how full the context window is, the percent, tokens against the window, a 12-turn chart, and the change since the last turn. Plays thunder when the window fills into Storm or Compact soon (sound is off by default).
Blast Radius (blast-radius)availableBefore Claude runs a risky shell command (rm -rf, git reset --hard, git push --force, ...), it holds the command and asks Proceed or Cancel in the question dialog, with a read-only preview of what would change. No band row. No model tokens. See "Blast Radius".
Ship Gate (ship-gate)availableTwo checks on shell commands. Publish gate: before git push, gh repo create, a change to GitHub Pages (gh api .../pages) and gh repo edit --visibility public, it asks Proceed or Cancel, with the remote, the visibility, the author e-mails of the unpushed commits and the lines of the tree that hold a personal string. Tested? gate: it denies git commit of code files when no test, type check or validator has passed on the current tree. No band row. No model tokens. See "Ship Gate".
Cache Keeper (cache-keeper)availableOne row above the prompt: a countdown of how long the prompt cache stays warm after the last response, the context size, and a compact button. A toast in the last 5 minutes. No model tokens. See "Cache Keeper" and "Compatibility".
Prompt Queue (prompt-queue)availableType /q <text> while Claude works to stack follow-up prompts. They are sent one at a time, each when the turn before it ends. One row above the prompt shows what waits. Sends prompts by itself, with your permissions: see "Prompt Queue". No model tokens of its own. See also "Compatibility".
Wait What (wait-what)available, OFF by defaultAfter a long answer, a retell in plain words (at most 2 short lines) in the band above the prompt. Spends model tokens: each retold answer is one call to the cheapest model, on your own plan. At most 30 calls an hour. See "Wait What" and "Compatibility".
Snake (snake)availableType /snake to play a small Snake game in a pane. It pauses when Claude finishes (PAUSED – Claude finished) and goes on when you send the next prompt. Keys w a s d p r, which work only while the pane has the keyboard. Terminal only. No band row. No model tokens. See "Snake" and "Compatibility".

Token Weather bands:

Percent of windowIcon and wordColour
under 25☀ Clearyellow
25 to 49☁ Cloudycyan
50 to 74☂ Showersblue
75 to 89☇ Stormmagenta
90 and over↯ Compact soonred

The window size is read from Claude Code (context.window). It is not fixed in the code.

Cache Keeper

Claude Code keeps your conversation in the model's prompt cache. While the cache is warm, the next prompt re-reads the conversation at the cheap cached rate. After a pause longer than the cache lifetime, the cache is gone and the next prompt processes the whole conversation again. Cache Keeper counts down that lifetime, so you can compact, or send a prompt, before the cache goes cold. It costs no model tokens, and makes no network call.

What the row says (the warm state was seen in a real terminal; the cooling and cold states are text only):

Time leftRowColour
more than 5 minutes⏱ cache warm 43m left · 160k tokens [compact]default
5 minutes or less⏱ cache cools in 4m · 160k tokens [compact]yellow
none⏱ cache cold: next prompt re-reads 160k tokens uncached [compact]red
a compaction runs⏱ compacting…yellow
  • The tokens are the context size that Claude Code reports after the response ($.session.usage()). When it cannot be read, the row has no tokens part, and the cold row says "the whole conversation". No price is shown: the rate depends on the model and the plan.
  • "uncached" is exact. A cold cache is not billed at the "full" input price: Anthropic's API reference bills a cache write at 1.25 times (5 minute cache) or 2 times (1 hour cache) the base input price.
  • The row shows after the first response, and not while a turn runs (each request of the turn refreshes the cache). It restarts at every response of the main conversation.
  • The toast Prompt cache cools in 5m. Send a prompt or compact first. shows once per response, when the cache enters its last 5 minutes. It shows from the minute timer, so it can come up to 1 minute late. A lifetime of 5 minutes or less has no warm part, so it gets no toast.
  • The compact button runs /compact ($.session.compact(), trigger plugin). It is a click. It has no hotkey. To press it with the keyboard, focus the band (ctrl+x then Tab) and press Enter on it. This keyboard path was not checked. While any compaction runs, the row says compacting… and the button is gone, and a second press is ignored (the shared lock, see "Compatibility"). If Claude Code refuses (it rejects while a turn runs, and a hook can veto), a toast says so and the row stays.
  • After a compaction (yours, an auto-compaction, or the button) and after /clear, the row goes until the next response: the new, shorter history has no cache entry yet.

The cache lifetime

Settled from the Claude Code documentation (code.claude.com/docs/en/prompt-caching, "Cache lifetime") and the API reference (platform.claude.com/docs/en/build-with-claude/prompt-caching), read on 2026-10-02:

Where the main conversation runsLifetime
Claude subscription, within the plan's included usage1 hour
Claude subscription, after the plan usage ran out and usage credits are used5 minutes
API key, or a cloud provider (Bedrock, Google Cloud's Agent Platform, Foundry)5 minutes

The person can choose it in Claude Code: the promptCacheTtl setting or the CLAUDE_CODE_PROMPT_CACHE_TTL variable (5m or 1h, from Claude Code 2.1.242), and ENABLE_PROMPT_CACHING_1H=1 or FORCE_PROMPT_CACHING_5M=1. Each request that reads the cache refreshes the timer. The API counts the lifetime from the start of the request that wrote or read the entry, not from the end of its response.

The declared plugin API does not say which case you are in. So the lifetime is the setting cacheTtlMinutes, and its default is 60, the documented lifetime on a subscription. On an API key, a cloud provider, or usage credits, set it to 5. Cache Keeper does not read promptCacheTtl or the variables. A value that is not a number above 0 and up to 1440 counts as 60.

Limits of the countdown:

  • It starts at the end of the response. The API's lifetime starts at the start of the last request of the turn, so Cache Keeper can show more time than is left, by about the duration of that last request.
  • It is a countdown of the timer, not a reading of the real cache. A model switch, a changed tool list and the other actions in Anthropic's "Actions that invalidate the cache" list make the cache cold at any time. Cache Keeper does not see them.
  • A subagent has its own cache and leaves the main conversation's cache as it was. So a subagent's turn does not restart the clock. A fork that reads the main conversation's cache is not seen either.
  • A turn that you interrupt, or that fails, restarts the clock only when Claude Code reported its token usage. If it did not, the earlier time stays: the safe side.
  • No sound. Plugin sound plays only on macOS, and the one sound in the pack (thunder) means a full context window.

Switch it off: /mods off cache-keeper (at once), or set cacheKeeper to false.

Wait What

Wait What spends model tokens, and it is OFF by default. After a long answer it sends the answer's text to a model and shows the reply, a retell in plain words, in the band above the prompt. The retell is not in the transcript and not in the model's context.

What it costs and what it sends:

  • Each retold answer is one call to the model alias haiku (the cheapest: no dated model id is written in the code), made through your own Claude Code session on your own plan.
  • What goes out: a fixed instruction of about 50 words, and the answer's text. Longer than 4,000 characters, the answer is cut to its start and its end (about 40% and 60%) with a marker between them. At four characters to a token, 4,000 characters are about 1,000 tokens. This is an estimate: no call was measured.
  • What comes back: a reply capped at 120 tokens (maxTokens). That is about one short reply for each answer. The call is cut after 15 seconds and then shows the note model call timed out or was cut (aborted).
  • No history is sent, so the call does not touch your conversation or its prompt cache.
  • The text of your answers leaves your machine for the model provider, as your own prompts do. Another plugin that hooks the model.complete call can read the request. Do not turn it on for work whose text must not go to the model a second time.
  • The cap is maxModelCallsPerHour (default 30), counted in a rolling hour, per session. See "The hourly cap".

Turn it on: /mods on wait-what (at once, kept across sessions), or set waitWhat to true (see "Settings"). Turn it off: /mods off wait-what.

When an answer is retold. All of these must hold:

RuleDetail
The mod is on/mods, then the setting, then the default (off).
The main conversationA subagent's turn is never retold, and it does not clear the retell of the main answer.
A real answerThe turn ended with the reason answer. An interrupt, an error and a refusal are not retold.
Long enough200 characters or more, after trimming. A shorter answer is not retold: it is already short. The number is MIN_ANSWER_CHARS in hooks/retell.ts.
A terminal is in use$.session.surfaces() includes terminal. If it does not, or it cannot be read, there is no call, and the band says which of the two it was (see "If no retell appears"). The row is drawn only on the terminal, so a call without a terminal would spend tokens for nothing.
The budget allows itSee "The hourly cap".
No compaction runs, no other model call runsThe shared lock is free. See "Compatibility".
The clock can be readA clock that cannot be read cannot count the hour, so there is no call.

An answer that is long and from the main conversation, and that gets no call or no retell, leaves a one-line note in the band. See "If no retell appears".

What the row says (a retell was seen in a real terminal as two lines, each cut with an ellipsis at the band width):

wait what: The build failed because a test reads a setting that was renamed.
           Rename the setting in the test file, then run the build again.
  • The reply is the model's, cleaned: terminal escape sequences, control characters and invisible characters are removed, list, heading and bold marks are removed, and spaces are folded. At most 2 lines are kept. A line longer than the band is cut with …. One long line is split at a space into two. Characters are counted, not terminal cells, so a wide character can make a line longer than its count: the band's own clip then cuts it.
  • A one-line retell takes one row of the band, a two-line retell takes two (see "How mods share the band").
  • While the call runs, nothing is drawn. The row is drawn while no turn runs, and it goes at the next prompt (turn.start), at /clear, and when a newer answer ends.
  • A reply that comes after any of those is dropped. A call in flight is cancelled at the next prompt and at the end of the session. /mods off wait-what during a call lets that call end (15 seconds at most) and drops its reply.
  • A failed call (an API error, an empty reply, a timeout, or a model that is blocked) shows one dim note line in the band that names the reason, shows no toast, and writes one line to the UI log: mod-pack: wait-what: <the same note>. It still counts toward the hourly cap, so a failing model cannot be called without limit.
  • No sound. No hotkey: the row has no Button.
  • A retell is a model's summary of the answer and can be wrong. Read the answer itself for anything that matters.

If no retell appears

After a long answer (200 characters or more) of the main conversation, Wait What draws either a retell or one dim line, wait what: <note>, that says why there is no retell. The note goes at the next prompt. A failed model call also writes the note to the UI log as mod-pack: wait-what: <note>, which stays when the band is clipped.

Note in the bandWhat it meansWhat to do
no terminal surface seen (saw: ...)$.session.surfaces() was read and the terminal is not in the list. The names after saw: are the surfaces that it listed (none when the list was empty). No model call was made.Wait What draws on the terminal only. Check that this session draws on a terminal. If it does, send the text of this note with a bug report: it shows what the engine listed.
could not read the session surfaces$.session.surfaces() rejected. The type declarations say it never rejects, so this note is a guard that is not expected to show. No model call was made.Send a new prompt. If the note comes back every time, report it.
clock unreadableThe engine's clock gave no time, so the hourly count cannot be kept. No model call was made.Send a new prompt. If the note comes back every time, report it.
a compaction is runningA compaction held the shared lock when the answer ended. No model call was made, and no call was counted.Ask again after the compaction ends.
a queued prompt is being sentThe Prompt Queue held the lock. No model call was made.None. The answer to the queued prompt is retold.
an earlier retell call is still runningA call for an earlier answer had not ended (15 seconds at most). No call was made for this answer.Ask again after a few seconds.
another automatic action holds the lockThe lock was held by a holder that this mod does not name.Report it, with what was running.
model call failed (api-error, status N, kind)The model call ran and the API answered with an error. N is the HTTP status (none when no response came). kind is the error kind that Claude Code names, for example rate_limit, overloaded or authentication_failed.rate_limit and overloaded: wait and ask again. authentication_failed: check the login of Claude Code. The call counted toward the hourly cap.
model returned no text (empty-reply)The model answered with no text.Ask again. If it repeats, report it.
model call timed out or was cut (aborted)The call ran past 15 seconds, or Claude Code cut it.Ask again. If it repeats, the model may be slow on your plan.
model call refused by Claude Code (rejected: ...)Claude Code refused to send the request (for example, the haiku alias is not allowed in your setup). The text after rejected: is Claude Code's own.Check that your plan or settings allow the haiku alias.
reply had no printable textThe model answered, and nothing was left after the reply was cleaned of escape and control characters.Ask again.
wait-what: hourly limit reachedThe hourly cap refused the call.Wait, or raise maxModelCallsPerHour. See "The hourly cap".

These cases draw no note: a short answer (under 200 characters), a subagent's turn, and a turn that was interrupted, failed or refused. They are normal skips.

When the band shows nothing, type /mods. Under Wait What, while it is ON, a line last: ... gives the same note, retell shown, hourly limit reached, or no retell needed: ... with the reason of a normal skip (the length of the answer, or why the turn ended). It shows the last outcome also when the band had no room to draw it. If there is no last: line, no outcome is stored: no main turn has finished since the last prompt, or the call is still running, or the turn.complete hook was not reached. The last case was not seen on a real screen.

The hourly cap

  • The cap is the setting maxModelCallsPerHour, default 30. A call counts from the moment it starts, success or failure. The window is a rolling hour: a call stops counting exactly one hour after it started.
  • 0 is valid and means no call at all. A value that is not a number from 0 to 1000 (negative, text, or above 1000) counts as 30.
  • When the cap refuses a call, nothing is sent and the band shows one muted line, wait-what: hourly limit reached, until the next prompt. The next answer is retold as soon as the oldest call has left the hour.
  • The count is kept in session state. It is meant to survive a hot reload and /clear (shown in the tests only, not in a real session). A new session starts at zero, so the cap is per session, not per account. If you run many sessions at once, each has its own cap.
  • If the clock is set back, a call that is then in the future counts as made now, and leaves one hour after the next finished turn.
  • An answer that is skipped (too short, a subagent, interrupted) spends nothing.

Limits:

  • The kit cannot show the real model. Every reply in the tests is a stub. Whether haiku is allowed in your setup, what it answers, what one retell costs on your plan, and how long it takes were not measured. If the model is blocked, each answer shows the note model call refused by Claude Code (rejected: ...) and logs the same text.
  • It was not checked that the call stays out of the transcript on a real session. The type declarations say $.model.complete runs "with no history" and the plugin calls no append, no prompt and no tool: so the transcript and the model context are only read from, per those declarations.
  • Whether the retell is readable at your terminal width was not seen.

Prompt Queue

Prompt Queue sends prompts by itself, and they run with your permissions. You type /q <text> while Claude works. When the turn ends, the first queued prompt is sent to Claude as if you had typed it at that moment, with the same tools and the same permission rules as any prompt of yours. Whatever it asks Claude to do, Claude does it, including edits and shell commands that your permission mode allows without asking, and nobody is watching at that moment. Queue only what you would be willing to send unattended, and read the queue (/q) before you leave it. The mod spends no model tokens of its own: each sent prompt is a normal turn of your session, on your plan.

The commands (/q runs while a turn is in flight: it is registered as an immediate command):

You typeEffect
/q <text>Add a prompt to the end of the queue. The text is trimmed. At most 20 prompts, at most 2000 characters each: a longer prompt is refused, not cut. An empty text is refused.
/q or /q listList the prompts, numbered from 1, and say whether the queue is paused.
/q rm <n>Remove prompt number n. The others keep their order.
/q clearRemove every prompt, and lift a pause.
/q pauseSend nothing until /q resume.
/q resumeLift a pause. If no turn is running, send the first prompt now.
/q add <text>Queue the text even if it starts with a command word (/q add pause).

Only the exact forms are commands: the whole argument is list, clear, pause or resume, or it is rm and a number. Anything else is a prompt, so /q rm the old files and /q clear the cache, then rerun the tests queue those sentences. A typo such as /q pasue queues the word. /q now (put a text into a running tool call) is not built: the declared API has no safe way to do it, and a prompt sent into a running turn is never folded into it ($.prompt.submit always waits for an idle session).

When a prompt is sent:

RuleDetail
The mod is on/mods, then the setting promptQueue, then the default (on). /mods off prompt-queue also empties the queue, so that prompts you forgot cannot send after a later /mods on. Off: /q answers that the mod is off, queues nothing, and sends nothing.
A turn of the main conversation ended with an answerA subagent's turn never sends. A turn that you interrupted (Esc), or that ended with an error or a refusal, does not send: it pauses the queue. The row says why, and a toast says it once. Only /q resume or /q clear lifts the pause. A later answer does not. The pause happens only when prompts are waiting: with an empty queue, nothing is paused.
The queue is not pausedBy /q pause, by a turn that did not answer, or because Claude Code refused the last prompt.
No compaction is running, and no queued prompt is still on its wayThe shared lock. See "Compatibility". A prompt that cannot be sent for this reason stays in the queue.
It is the first send for this turnOne turn.complete sends at most one prompt, and the same turn never sends twice.

What the person types with no turn running: /q <text> sends the first prompt at once (nothing else would ever send it), and /q resume does the same. This also is how you restart a queue that waited for a compaction. The mod knows "a turn runs" from turn.start and turn.complete: after a hot reload it keeps the value in session state.

The prompt is sent with $.prompt.submit({ text, asUser: true }). The model reads the text bare, as your own words, with no "The mod-pack plugin sent a message" frame before it. The transcript still names the plugin, and every hook sees e.origin { kind: 'plugin', name: 'mod-pack', asUser: true }: both are what the declared API says, and neither was seen on a real screen. The declared API says @file mentions and pasted images are not expanded in a plugin's prompt. The send is made after turn.complete answered, never inside it: the declared API says a hook that waits for its own $.prompt.submit inside a turn waits for ever. For a /q that sends at once, the send is made from a 0 ms timer, because the engine refuses $.prompt.submit from inside a command.run hook ("it would wait on the turn this hook is holding; submit from a later event"). This was found with the test kit's host check, not guessed. If Claude Code refuses a prompt (a hook drops it, or the call fails), the prompt goes back to the top of the queue, the queue pauses, a toast says so, and one line goes to the UI log.

What the row says (text only: this row has not been seen drawn in a real terminal):

queue (3): 1 fix tests · 2 update docs · …
queue paused (3): you interrupted the turn · /q resume sends · /q clear drops
  • One row of the band, 1 line, drawn while a
Source 17 files
hooks/register.tsx 983 lines
1// The dispatcher. It registers each event ONCE (the engine refuses the same
2// event twice without a matcher) and hands the work to the features in FEATURES.
3// Every `$` call of the pack is written in this file; see hooks/feature.ts.
4//
5// To add a mod: write a Feature (hooks/feature.ts), import it, add it to FEATURES,
6// add a boolean to userConfig in .claude-plugin/plugin.json, and add its state
7// type to types/index.d.ts. The order of FEATURES is the row order in the band,
8// first on top, and so also the priority when the row budget is short.
9
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, PluginOptions, Register, RenderElement, ToolCallResult } from 'claude-code'
12
13import {
14  blastRadius, branchTargets, buildQuestion, cleanDryRun, CANCEL, classify, denyReason, DIALOG_HEADER, ENTRY_CAP, GIT_READ, hasGitOptions, isProceed,
15  MAX_PATHS, MAX_RISKS_PREVIEWED, NO_GIT_PREVIEW, NO_PREVIEW, OUTPUT_CAP, pathArgs, previewBranch, previewClean, previewPaths, previewPush,
16  previewStash, previewStatus, PROCEED, pushPlan, resolvePath, STEP_TIMEOUT_MS,
17} from './blast-radius'
18import type { GitOut, PathFact, Risk } from './blast-radius'
19import { cacheKeeper } from './cache-keeper'
20import type { Feature, FeatureContext, Lock, ModelAsk, ModelReply, Step } from './feature'
21import { runMods } from './mods-command'
22import { promptQueue } from './prompt-queue'
23import { runQ } from './queue'
24import type { Queue } from './queue'
25import {
26  buildPublishQuestion, codeFiles, commitDeny, describePublish, fingerprintOf, GITHUB_SLUG, personalTerms, plan, publishDeny, publishFailedDeny, SHIP_HEADER, shipGate,
27} from './ship-gate'
28import type { Commit, Publish, TestRun } from './ship-gate'
29import { isFeatureOn, isSoundAllowed, isSoundOn, layoutRows, parseOverrides, rowBudget, STORE_KEY } from './settings'
30import { MIN_COLUMNS, PANE_ROWS, snake, snakeView } from './snake'
31import { halt, newGame, parseBest, pauseFor, restart, resumeFrom, steer, step, toggle } from './snake-game'
32import type { Dir, SnakeGame } from './snake-game'
33import { tokenWeather } from './token-weather'
34import { waitWhat } from './wait-what'
35import type { ModPackFeatureStates } from '../types'
36
37// The order is the row order in the band. Blast Radius, Ship Gate and Snake draw no row (Snake draws a pane).
38// Prompt Queue stands before Wait What on purpose: its row is 1 line and must not be starved by a
39// 2-line retell.
40const FEATURES: readonly Feature[] = [tokenWeather, cacheKeeper, promptQueue, waitWhat, blastRadius, shipGate, snake]
41
42// Every feature's state, by feature id.
43const featureStates = atom({ plugin: 'mod-pack', key: 'features' } as const, {} as ModPackFeatureStates)
44
45// `isBusy` and `lock` are added by `dispatch`, at the moment of the call.
46type Active = { feature: Feature; ctx: Omit<FeatureContext, 'isBusy' | 'lock'> }[]
47
48// The features that are ON now, each with what it may do. Read at call time,
49// so a /mods change applies at once, with no reload.
50async function active($: EngineInterface, options: PluginOptions): Promise<Active> {
51  const overrides = parseOverrides(await $.store.get(STORE_KEY))
52  const isGlobalSoundOn = isSoundOn(overrides, options)
53  const now = await $.clock.now().catch(() => NaN)
54  return FEATURES.filter(f => isFeatureOn(f, overrides, options)).map(feature => ({
55    feature,
56    ctx: { isSoundAllowed: isSoundAllowed(feature, true, isGlobalSoundOn), now, options },
57  }))
58}
59
60// Runs `call` on each feature that is ON (or only on the one named by `only`), keeps the state
61// it returns, shows the toast it asks for, plays the sound it asks for when sound is allowed, and
62// starts the model call it asks for, and sends the prompt it asks for. One feature failing never
63// stops the others.
64async function dispatch($: EngineInterface, options: PluginOptions, call: (feature: Feature, state: unknown, ctx: FeatureContext) => Step<unknown> | undefined, only?: string) {
65  for (const { feature, ctx } of await active($, options)) {
66    if (only !== undefined && feature.id !== only) continue
67    // A feature that a command also changes (`/q`) runs one call at a time, in the order they came.
68    if (feature.isSerial) await inOrder(() => dispatchOne($, options, feature, ctx, call))
69    else await dispatchOne($, options, feature, ctx, call)
70  }
71}
72
73async function dispatchOne($: EngineInterface, options: PluginOptions, feature: Feature, ctx: Active[number]['ctx'], call: (feature: Feature, state: unknown, ctx: FeatureContext) => Step<unknown> | undefined) {
74  let stop: AbortController | undefined
75  let sending: object | undefined
76  try {
77    const states: Record<string, unknown> = await read($, featureStates)
78    // `isBusy` and `lock` are read here, in the same moment as the call, and the lock for a model call
79    // or a prompt is taken in the same moment as its answer: nothing can take the lock in between.
80    const step = call(feature, states[feature.id], { ...ctx, isBusy: busyWith !== undefined, lock: busyWith })
81    if (step?.ask) stop = beginAsk()
82    if (step?.submit !== undefined) {
83      sending = beginSubmit()
84      // The feature read the lock a moment ago, so this cannot happen. If it does, nothing is
85      // changed and nothing is sent: the prompt stays where it was.
86      if (!sending) return $.ui.log(`mod-pack: ${feature.id} did not send: the lock is held`)
87    }
88    if (step?.state !== undefined) {
89      const state = step.state
90      await update($, featureStates, all => ({ ...all, [feature.id]: state }))
91    }
92    if (step?.toast) $.ui.toast(step.toast)
93    if (step?.log) $.ui.log(`mod-pack: ${feature.id}: ${step.log}`)
94    if (step?.sound && ctx.isSoundAllowed) void $.audio.play({ asset: step.sound }).catch(() => undefined)
95    if (step?.ask && stop) {
96      // Detached: the event that raised this call never waits for the model.
97      void runAsk($, options, feature, step.ask, stop)
98      stop = undefined
99    }
100    if (step?.submit !== undefined && sending) {
101      // Detached: `$.prompt.submit` resolves when the new turn starts, and a hook that waits for it
102      // inside `turn.complete` would wait for ever.
103      void runSubmit($, options, feature, step.submit, sending)
104      sending = undefined
105      $.ui.invalidate('ui.render')
106    }
107  } catch (error) {
108    if (stop) releaseAsk(stop)
109    if (sending) releaseSubmit(sending)
110    $.ui.log(`mod-pack: ${feature.id} failed: ${String(error)}`)
111  }
112}
113
114// Runs the jobs of `inOrder` one after the other. Plain module state: a hot reload drops it.
115let orderTail: Promise<unknown> = Promise.resolve()
116function inOrder<T>(job: () => Promise<T>): Promise<T> {
117  const run = orderTail.then(job, job)
118  orderTail = run.catch(() => undefined)
119  return run
120}
121
122// ---- The shared automation lock ----------------------------------------------------
123// Only ONE automatic action runs at a time: a compaction, a model call of a mod (Wait What),
124// and an automatic prompt submission (Prompt Queue). `busyWith` names the holder, or is
125// undefined when free. It is plain module state on purpose: a hot reload drops it together
126// with the running work and the timers, so it cannot stay set. A mod that sends prompts or
127// calls a model by itself must check it first (`ctx.isBusy`, and `ctx.lock` for who holds it).
128//
129// A compaction always takes the lock, even from a model call or a prompt on its way: the engine's
130// own compaction cannot be refused, and the model call is a short side request with no history, so
131// the two do not disturb each other. The call then no longer holds the lock, and must not free it.
132//
133// Which holders block the Prompt Queue: a compaction, and another prompt of the queue that is still
134// on its way. A model call does not: the queue's prompt starts a turn, a new turn cancels the call
135// anyway, and `beginSubmit` cancels it at once and takes the lock.
136let busyWith: Lock | undefined
137
138// ---- Model calls (Wait What) ---------------------------------------------------------
139// At most ONE model call of a mod is in flight. `askStop` is its AbortController, and also the
140// proof of who owns the lock: only the owner frees it. A call is cancelled when a turn starts
141// or the session ends, and its reply is then dropped.
142let askStop: AbortController | undefined
143
144// Takes the lock for a model call. Undefined when the lock is held by someone else.
145function beginAsk(): AbortController | undefined {
146  if (busyWith !== undefined) return undefined
147  cancelAsk()
148  busyWith = 'model-call'
149  askStop = new AbortController()
150  return askStop
151}
152
153// The call ended: free the lock if this call still owns it.
154function releaseAsk(stop: AbortController) {
155  if (askStop !== stop) return
156  askStop = undefined
157  if (busyWith === 'model-call') busyWith = undefined
158}
159
160// Cancels the call in flight, if any, and frees its lock at once.
161function cancelAsk() {
162  const stop = askStop
163  if (!stop) return
164  stop.abort()
165  releaseAsk(stop)
166}
167
168// ---- Prompt submissions (Prompt Queue) ----------------------------------------------
169// At most ONE queued prompt is on its way. `submitOwner` is its token, and the proof of who owns the
170// lock: only the owner frees it. The lock is held from the decision to send until the prompt's turn
171// starts (`turn.start`), the call settles, the session ends, or SUBMIT_LOCK_MS has passed.
172let submitOwner: object | undefined
173const SUBMIT_LOCK_MS = 30_000
174
175// Takes the lock for a prompt. Undefined when a compaction or another prompt holds it. A model call
176// of a mod does not stop it: the call is cancelled here, and its reply is dropped.
177function beginSubmit(): object | undefined {
178  if (busyWith === 'compaction' || busyWith === 'prompt-submit') return undefined
179  cancelAsk()
180  busyWith = 'prompt-submit'
181  submitOwner = {}
182  return submitOwner
183}
184
185// Frees the lock if `owner` still owns it. With no `owner`: whoever owns it (a turn started, or the
186// session ended). A compaction that took the lock over is not freed: the holder is checked.
187function releaseSubmit(owner?: object) {
188  if (owner !== undefined && submitOwner !== owner) return
189  submitOwner = undefined
190  if (busyWith === 'prompt-submit') busyWith = undefined
191}
192
193// Sends one prompt for a feature, as the person's own words. It is called with `void`, so it never
194// throws. The call resolves when the new turn starts (or the prompt is queued behind a running one),
195// and a hook may answer it with `{ drop }`. A drop and a rejection both go back to the feature.
196async function runSubmit($: EngineInterface, options: PluginOptions, feature: Feature, text: string, owner: object) {
197  // If the call never settles, the lock is not held for ever.
198  const guard = $.clock.after(SUBMIT_LOCK_MS, () => releaseSubmit(owner))
199  try {
200    let why: string | undefined
201    try {
202      const result = await $.prompt.submit({ text, asUser: true })
203      if (result.drop !== undefined) why = `a hook dropped it: ${String(result.drop).slice(0, 120)}`
204    } catch (error) {
205      why = String(error).slice(0, 120)
206    }
207    guard.cancel()
208    releaseSubmit(owner)
209    if (why !== undefined) {
210      const reason = why
211      $.ui.log(`mod-pack: ${feature.id} could not send a prompt: ${reason}`)
212      await dispatch($, options, (f, state, ctx) => f.submitFailed?.(state, { text, why: reason }, ctx), feature.id)
213      $.ui.invalidate('ui.render')
214    }
215  } catch (error) {
216    $.ui.log(`mod-pack: ${feature.id} send failed: ${String(error)}`)
217  } finally {
218    guard.cancel()
219    releaseSubmit(owner)
220  }
221}
222
223// One model call for a feature, then the reply to the feature (`modelDone`). It is called with
224// `void`, so it never throws: every failure ends as "no reply". The call is `$.model.complete`:
225// no history, so the conversation and its cache are not touched. It always resolves a result,
226// and rejects only for a request the engine refuses to send (a blocked model). A failed call goes back
227// with its reason (`api-error` and its status, `empty-reply`, `aborted`, or `rejected`).
228async function runAsk($: EngineInterface, options: PluginOptions, feature: Feature, ask: ModelAsk, stop: AbortController) {
229  try {
230    let reply: ModelReply = { isAnswered: false, reason: 'rejected' }
231    try {
232      const result = await $.model.complete(
233        { model: ask.model, system: ask.system, prompt: ask.prompt, maxTokens: ask.maxTokens, timeoutMs: ask.timeoutMs, ...(ask.effort ? { effort: ask.effort } : {}) },
234        { signal: stop.signal },
235      )
236      if (result.isAnswered) reply = { isAnswered: true, text: result.text }
237      else if (result.reason === 'api-error') reply = { isAnswered: false, reason: 'api-error', status: result.status, error: String(result.error) }
238      else reply = { isAnswered: false, reason: result.reason }
239    } catch (error) {
240      // The engine refused to send the request. The feature gets the text, and logs it (`Step.log`).
241      reply = { isAnswered: false, reason: 'rejected', error: String(error).slice(0, 200) }
242    } finally {
243      releaseAsk(stop)
244    }
245    // Cancelled (a turn started or the session ended): the reply is of no use.
246    if (stop.signal.aborted) return
247    await dispatch($, options, (f, state, ctx) => f.modelDone?.(state, { turnId: ask.turnId, reply }, ctx), feature.id)
248    $.ui.invalidate('ui.render')
249  } catch (error) {
250    $.ui.log(`mod-pack: ${feature.id} model reply failed: ${String(error)}`)
251  }
252}
253
254// ---- The minute timer ---------------------------------------------------------------
255// Features with a `tick` (Cache Keeper) get one call a minute. The timer is started
256// from events (session.start, turn.start, turn.complete), not only session.start,
257// because a /clear raises no session.start. A hot reload drops the timer and resets
258// `ticker`, so the next event starts one. It stops at session.end, and by itself
259// when no feature with a `tick` is ON.
260let ticker: { cancel: () => void } | undefined
261
262function stopTicker() {
263  ticker?.cancel()
264  ticker = undefined
265}
266
267async function minuteTick($: EngineInterface, options: PluginOptions) {
268  try {
269    if (!(await active($, options)).some(({ feature }) => feature.tick)) return stopTicker()
270    await dispatch($, options, (feature, state, ctx) => feature.tick?.(state, ctx))
271    $.ui.invalidate('ui.render')
272  } catch (error) {
273    $.ui.log(`mod-pack: minute tick failed: ${String(error)}`)
274  }
275}
276
277async function armTicker($: EngineInterface, options: PluginOptions) {
278  if (ticker || !(await active($, options).catch(() => [])).some(({ feature }) => feature.tick)) return
279  ticker = $.clock.every(60_000, () => void minuteTick($, options))
280}
281
282// ---- Cache Keeper -------------------------------------------------------------------
283
284// A compaction of the main conversation stands: every feature that watches for it clears.
285async function afterCompaction($: EngineInterface, options: PluginOptions) {
286  await dispatch($, options, (feature, state, ctx) => feature.compacted?.(state, ctx))
287  $.ui.invalidate('ui.render')
288}
289
290// What the band's "compact" button does. It ignores the press while any compaction is
291// running (the lock), and says so by a toast while a model call or a queued prompt holds the lock
292// (a few seconds at most: a call is cut after 15 seconds). `$.session.compact()` rejects while a turn runs and
293// resolves `{ skip }` when a hook vetoes: both become a toast, and the row stays as it was.
294// It runs through every hook but the calling one, so this plugin's own
295// `session.compact` hook does not see this call: the lock and the clear are done here.
296async function compactNow($: EngineInterface, options: PluginOptions) {
297  if (busyWith === 'compaction') return
298  if (busyWith === 'prompt-submit') return $.ui.toast('Cache Keeper: Prompt Queue is sending a prompt. Press compact again in a moment.')
299  if (busyWith !== undefined) return $.ui.toast('Cache Keeper: a Wait What retell is running. Press compact again in a few seconds.')
300  busyWith = 'compaction'
301  $.ui.invalidate('ui.render')
302  try {
303    const result = await $.session.compact()
304    if (result.skip === undefined) await afterCompaction($, options)
305    else $.ui.toast(`Cache Keeper: compaction skipped: ${result.skip}`)
306  } catch (error) {
307    $.ui.toast(`Cache Keeper: could not compact now (${String(error).slice(0, 120)})`)
308  } finally {
309    busyWith = undefined
310    $.ui.invalidate('ui.render')
311  }
312}
313
314// ---- Blast Radius -----------------------------------------------------------------
315// The pure rules and text are in blast-radius.ts. The engine calls are here.
316
317// Read at call time, so `/mods off blast-radius` applies at once, with no reload.
318async function isBlastRadiusOn($: EngineInterface, options: PluginOptions) {
319  return isFeatureOn(blastRadius, parseOverrides(await $.store.get(STORE_KEY)), options)
320}
321
322// One read-only git call: argv, no shell, its own timeout, its output cut to OUTPUT_CAP.
323// Undefined when git cannot start or runs out of time. Never throws.
324async function git($: EngineInterface, args: readonly string[], cwd: string | undefined): Promise<GitOut | undefined> {
325  const run = await $.process.run([...GIT_READ, ...args], { cwd, timeoutMs: STEP_TIMEOUT_MS }).catch(() => undefined)
326  if (!run) return undefined
327  return { ok: run.exitCode === 0, text: run.stdout.slice(0, OUTPUT_CAP), isCut: run.isStdoutTruncated || run.stdout.length > OUTPUT_CAP }
328}
329
330// How many entries are inside a directory, counting every level. At most ENTRY_CAP
331// entries are visited, and a symbolic link is an entry that is never entered
332// ($.fs.list says `other` for a link), so a link cycle cannot loop.
333async function countEntries($: EngineInterface, root: string) {
334  const waiting = [root]
335  let entries = 0
336  while (waiting.length > 0 && entries <= ENTRY_CAP) {
337    const dir = waiting.pop() as string
338    for (const entry of await $.fs.list(dir).catch(() => [])) {
339      entries++
340      if (entry.kind === 'dir') waiting.push(`${dir.replace(/[\\/]+$/, '')}/${entry.name}`)
341    }
342  }
343  return { entries: Math.min(entries, ENTRY_CAP), isCapped: entries > ENTRY_CAP }
344}
345
346// What one path is. `path` is as typed (it is shown), `resolved` is where to look.
347async function describePath($: EngineInterface, path: string, resolved: string): Promise<PathFact> {
348  const stat = await $.fs.stat(resolved).catch(() => undefined)
349  if (!stat) return { path, kind: 'missing' }
350  if (stat.isLink) return { path, kind: 'link' }
351  if (stat.kind === 'dir') return { path, kind: 'dir', ...(await countEntries($, resolved)) }
352  return { path, kind: stat.kind }
353}
354
355// The lines that say what one risky segment would change. Best effort: any failure
356// becomes "no preview available". Never throws, so the dialog is always shown.
357async function previewRisk($: EngineInterface, risk: Risk, cwd: string | undefined): Promise<string[]> {
358  try {
359    if (risk.preview === 'paths') {
360      const args = pathArgs(risk.segment)
361      // `cd x && rm -rf y`: y is not in the session folder, so it is not looked at.
362      const looked = risk.hasCd || cwd === undefined ? [] : args.paths.slice(0, MAX_PATHS)
363      const facts = await Promise.all(looked.map(path => describePath($, path, resolvePath(cwd as string, path))))
364      return previewPaths(facts, args, risk.hasCd)
365    }
366
367    // Every other kind runs git. The command's own git options (-C, -c) are not followed.
368    if (hasGitOptions(risk.segment)) return [NO_GIT_PREVIEW]
369
370    if (risk.preview === 'status-all' || risk.preview === 'status-worktree') {
371      const [status, shortstat] = await Promise.all([git($, ['status', '--porcelain'], cwd), git($, ['diff', '--shortstat', '--no-ext-diff', 'HEAD'], cwd)])
372      return previewStatus(status, shortstat, risk.preview === 'status-all' ? 'all' : 'worktree')
373    }
374
375    if (risk.preview === 'clean') {
376      const dryRun = cleanDryRun(risk.segment)
377      return dryRun ? previewClean(await git($, dryRun, cwd)) : [NO_GIT_PREVIEW]
378    }
379
380    if (risk.preview === 'push') {
381      const plan = pushPlan(risk.segment)
382      const [branch, upstream, count, log] = await Promise.all([
383        git($, ['rev-parse', '--abbrev-ref', 'HEAD'], cwd),
384        git($, ['rev-parse', '--abbrev-ref', '@{u}'], cwd),
385        plan.range ? git($, ['rev-list', '--count', plan.range], cwd) : undefined,
386        plan.range ? git($, ['log', '--oneline', '-n', '10', plan.range], cwd) : undefined,
387      ])
388      return previewPush({ branch, upstream, count, log, plan })
389    }
390
391    if (risk.preview === 'branch') {
392      const items = await Promise.all(branchTargets(risk.segment).map(async name => ({ name, count: await git($, ['rev-list', '--count', `HEAD..${name}`], cwd) })))
393      return previewBranch(items)
394    }
395
396    return previewStash(await git($, ['stash', 'list'], cwd))
397  } catch (error) {
398    $.ui.log(`mod-pack: blast-radius preview failed: ${String(error)}`)
399    return [NO_PREVIEW]
400  }
401}
402
403// Asks the person. Resolves the label chosen, or the text typed under "Other", or
404// undefined when `$.ui.ask` rejects. It rejects when the dialog is dismissed, and in a
405// `claude -p` run, where nobody can be asked. While the question is open a Snake game that is
406// running is paused (`holdSnake`): the dialog takes the keyboard, so the person cannot steer.
407async function askPerson($: EngineInterface, options: PluginOptions, question: string, header = DIALOG_HEADER): Promise<string | undefined> {
408  await holdSnake($, options, true)
409  try {
410    return await $.ui.ask(question, { options: [PROCEED, CANCEL], header }).then(
411      answer => answer,
412      () => undefined,
413    )
414  } finally {
415    await holdSnake($, options, false)
416  }
417}
418
419// The `tool.call` guard for the shell tools. A safe command, or the mod switched off,
420// goes straight on. A risky one waits for the dialog. Only an explicit Proceed lets it
421// run. Cancel, a dismissed dialog, no one to ask, and any other answer deny it (fail
422// closed), so a `claude -p` run denies every risky command.
423async function guardShell($: EngineInterface, options: PluginOptions, command: string, proceed: () => Promise<ToolCallResult>): Promise<ToolCallResult> {
424  if (!(await isBlastRadiusOn($, options))) return proceed()
425  const risks = classify(command)
426  if (risks.length === 0) return proceed()
427
428  const cwd = await $.session.cwd().catch(() => undefined)
429  const preview: string[] = []
430  for (const risk of risks.slice(0, MAX_RISKS_PREVIEWED)) preview.push(...(await previewRisk($, risk, cwd)))
431  if (risks.length > MAX_RISKS_PREVIEWED) preview.push(`...and ${risks.length - MAX_RISKS_PREVIEWED} more risky parts of the command`)
432
433  const answer = await askPerson($, options, buildQuestion(command, risks, preview))
434  return isProceed(answer) ? proceed() : { deny: denyReason(answer, risks.map(r => r.kind)) }
435}
436
437// If the guard crashes or runs out of its 10 seconds, the engine skips the hook and the
438// command would run. This handler runs in its place. It has 1 second of its own time,
439// and the time inside `$` calls (the dialog too) does not count. It asks again, without
440// a preview. Only a command it can show to be safe, or the mod being off, goes on unasked.
441async function guardShellFailed($: EngineInterface, options: PluginOptions, command: string, failure: { message?: string; called: boolean }, proceed: () => Promise<ToolCallResult>): Promise<ToolCallResult> {
442  // The guard had already called `next`: replay what it settled to, and do not ask twice.
443  if (failure.called) return proceed()
444  if (!(await isBlastRadiusOn($, options).catch(() => true))) return proceed()
445
446  let risks: Risk[] | undefined
447  try {
448    risks = classify(command)
449  } catch {
450    risks = undefined
451  }
452  if (risks?.length === 0) return proceed()
453
454  const why = failure.message ? ` (${failure.message.slice(0, 120)})` : ''
455  const question = buildQuestion(command, risks ?? [], [`${NO_PREVIEW}: the Blast Radius check itself failed${why}.`])
456  const answer = await askPerson($, options, question)
457  return isProceed(answer) ? proceed() : { deny: denyReason(answer, (risks ?? []).map(r => r.kind)) }
458}
459
460// ---- Ship Gate --------------------------------------------------------------------
461// The rules and the text are in ship-gate.ts. The engine calls are here. It runs inside the same
462// `tool.call` hooks as Blast Radius (the engine refuses the same matcher twice), after it.
463
464// A remote named in `git push <remote>`: a plain name only. Anything else is looked up as the upstream.
465const SAFE_REMOTE = /^[\w.-]+$/
466const VISIBILITY_TIMEOUT_MS = 5000 // `gh repo view` goes to the network
467
468// Read at call time, so `/mods off ship-gate` applies at once, with no reload.
469async function isShipGateOn($: EngineInterface, options: PluginOptions) {
470  return isFeatureOn(shipGate, parseOverrides(await $.store.get(STORE_KEY)), options)
471}
472
473// What the tree looks like now: the `git status` text and a fingerprint of the changed paths plus `git diff HEAD`.
474// The status letters are left out of the fingerprint, so a `git add` between a check and a commit (` M` to `M `)
475// is not an edit. Undefined when git cannot read the folder. `git()` cuts each output at OUTPUT_CAP characters.
476async function treeFingerprint($: EngineInterface, cwd: string | undefined) {
477  const [status, diff] = await Promise.all([
478    git($, ['status', '--porcelain', '-uall'], cwd),
479    git($, ['diff', '--no-ext-diff', '--no-textconv', '--no-color', 'HEAD'], cwd),
480  ])
481  if (!status?.ok) return undefined
482  const paths = status.text.split(/\r?\n/).filter(Boolean).map(line => line.slice(3)).join('\n')
483  return { status: status.text, fingerprint: fingerprintOf(paths, diff?.ok ? diff.text : '') }
484}
485
486async function noteShip($: EngineInterface, patch: { gate?: string; test?: { command: string; at: number; fingerprint: string } }) {
487  await update($, featureStates, all => ({ ...all, 'ship-gate': { ...all['ship-gate'], ...patch } }))
488}
489
490// The Tested? gate for one commit. Returns the reason to deny, or undefined to let it go on. It lets the
491// command go on when it cannot judge: the folder is not known, git cannot read it, only text files change.
492async function shipCommit($: EngineInterface, commit: Commit): Promise<string | undefined> {
493  if (commit.isSkipped || commit.isCovered || !commit.dir.isKnown) return undefined
494  const here = await treeFingerprint($, commit.dir.path)
495  if (!here) return undefined
496  const files = codeFiles(here.status)
497  if (files.length === 0) return undefined
498  const test = (await read($, featureStates))['ship-gate']?.test
499  if (test && test.fingerprint === here.fingerprint) return undefined
500  const now = await $.clock.now().catch(() => NaN)
501  await noteShip($, { gate: `denied a commit of ${files.length} code ${files.length === 1 ? 'file' : 'files'}: no passing check on this tree` })
502  return commitDeny(files, test, now)
503}
504
505// The facts for one publish command: what `git`, `gh` and `git grep` say. Every step is best effort and
506// never throws, so the dialog is always shown.
507async function publishFacts($: EngineInterface, options: PluginOptions, publish: Publish): Promise<string[]> {
508  try {
509    const cwd = publish.dir.path
510    if (!publish.dir.isKnown) return describePublish({ kind: publish.kind, hasSource: publish.hasSource, isFolderKnown: false, terms: [], grepRan: false })
511    const email = await git($, ['config', 'user.email'], cwd)
512    const terms = personalTerms({ path: cwd, email: email?.ok ? email.text.trim() : undefined, extra: options.shipGateTerms })
513
514    let remote: string | undefined
515    let url: string | undefined
516    let log: string | undefined
517    if (publish.kind === 'push') {
518      const upstream = await git($, ['rev-parse', '--abbrev-ref', '@{u}'], cwd)
519      const named = publish.remote && SAFE_REMOTE.test(publish.remote) ? publish.remote : undefined
520      remote = named ?? (upstream?.ok ? upstream.text.trim().split('/')[0] : undefined) ?? 'origin'
521      const place = await git($, ['remote', 'get-url', remote], cwd)
522      url = place?.ok ? place.text.trim() : undefined
523      const range = !named && upstream?.ok ? ['@{u}..HEAD'] : ['HEAD', '--not', `--remotes=${remote}`]
524      const listed = await git($, ['log', '-n', '200', '--format=%h%x09%ae%x09%ce%x09%s', ...range], cwd)
525      log = listed?.ok ? listed.text : undefined
526    }
527
528    // `gh repo view` names the repository by its GitHub slug when the remote is one. It needs the network.
529    const slug = url ? GITHUB_SLUG.exec(url) : null
530    const view = publish.kind === 'create'
531      ? undefined
532      : await $.process.run(['gh', 'repo', 'view', ...(slug ? [`${slug[1]}/${slug[2]}`] : []), '--json', 'visibility', '--jq', '.visibility'], { cwd, timeoutMs: VISIBILITY_TIMEOUT_MS }).catch(() => undefined)
533    const visibility = view?.exitCode === 0 ? view.stdout.trim() || undefined : undefined
534
535    // The text files of the tree that would be published. `git grep` ends with 1 when nothing matches.
536    const isTreeOut = (publish.kind === 'push' || publish.hasSource) && terms.length > 0
537    const grep = isTreeOut ? await git($, ['grep', '-n', '-I', '-i', '-F', ...terms.flatMap(t => ['-e', t]), 'HEAD'], cwd) : undefined
538    const head = isTreeOut ? await git($, ['rev-parse', '--verify', 'HEAD'], cwd) : undefined
539    const hits = grep && head?.ok && (grep.ok || grep.text === '') ? (grep.ok ? grep.text : '') : undefined
540
541    return describePublish({
542      kind: publish.kind,
543      ...(remote ? { remote } : {}),
544      ...(url ? { url } : {}),
545      ...(visibility ? { visibility } : {}),
546      ...(publish.flag ? { flag: publish.flag } : {}),
547      hasSource: publish.hasSource,
548      isFolderKnown: true,
549      terms,
550      ...(log !== undefined ? { log } : {}),
551      ...(hits !== undefined ? { hits } : {}),
552      grepRan: hits !== undefined,
553    })
554  } catch (error) {
555    $.ui.log(`mod-pack: ship-gate facts failed: ${String(error)}`)
556    return ['the check of the command failed, so nothing was looked at.']
557  }
558}
559
560// The publish gate for a command that has one or more publish parts: one dialog. Only an explicit
561// Proceed lets it run; Cancel, a dismissed dialog and no one to ask deny it (fail closed).
562async function shipPublish($: EngineInterface, options: PluginOptions, command: string, publishes: readonly Publish[]): Promise<string | undefined> {
563  const facts: string[] = []
564  for (const publish of publishes.slice(0, MAX_RISKS_PREVIEWED)) facts.push(...(await publishFacts($, options, publish)))
565  const kinds = publishes.map(p => p.kind)
566  const answer = await askPerson($, options, buildPublishQuestion(command, kinds, facts), SHIP_HEADER)
567  if (isProceed(answer)) {
568    await noteShip($, { gate: `person approved: ${kinds.join(', ')}` })
569    return undefined
570  }
571  await noteShip($, { gate: `held: ${kinds.join(', ')} (${answer === undefined ? 'no answer' : 'cancelled'})` })
572  return publishDeny(answer, kinds)
573}
574
575// Records a passing test-like command with the fingerprint of the tree it passed on. Never throws.
576async function noteTest($: EngineInterface, tests: readonly TestRun[]) {
577  try {
578    const last = tests[tests.length - 1]
579    if (!last?.dir.isKnown) return
580    const here = await treeFingerprint($, last.dir.path)
581    if (!here) return
582    await noteShip($, { test: { command: last.name, at: await $.clock.now().catch(() => NaN), fingerprint: here.fingerprint } })
583  } catch (error) {
584    $.ui.log(`mod-pack: ship-gate could not record a test: ${String(error)}`)
585  }
586}
587
588// The Ship Gate part of the `tool.call` guard. An ordinary command, or the mod switched off, goes
589// straight on. A publish waits for the dialog. A commit of code with no passing check is denied.
590// After the command ran, a test-like command that passed is recorded.
591async function guardShip($: EngineInterface, options: PluginOptions, command: string, proceed: () => Promise<ToolCallResult>): Promise<ToolCallResult> {
592  if (!(await isShipGateOn($, options))) return proceed()
593  const cwd = await $.session.cwd().catch(() => undefined)
594  const found = plan(command, cwd)
595  if (found.commits.length === 0 && found.publishes.length === 0 && found.tests.length === 0) return proceed()
596
597  for (const commit of found.commits) {
598    const reason = await shipCommit($, commit)
599    if (reason) return { deny: reason }
600  }
601  if (found.publishes.length > 0) {
602    const reason = await shipPublish($, options, command, found.publishes)
603    if (reason) return { deny: reason }
604  }
605
606  const result = await proceed()
607  const interrupted = (result.result as { interrupted?: boolean } | undefined)?.interrupted === true
608  if (found.tests.length > 0 && result.deny === undefined && !result.isError && !interrupted) await noteTest($, found.tests)
609  return result
610}
611
612// Both guards, Blast Radius first. The command runs only when neither holds it.
613function guardCommand($: EngineInterface, options: PluginOptions, command: string, proceed: () => Promise<ToolCallResult>): Promise<ToolCallResult> {
614  return guardShell($, options, command, () => guardShip($, options, command, proceed))
615}
616
617// The Ship Gate crashed before the command ran (a command that had already run is replayed by
618// `guardShellFailed`). A publish is refused; any other command goes on, as a safe command does there.
619async function shipFailed($: EngineInterface, options: PluginOptions, command: string, proceed: () => Promise<ToolCallResult>): Promise<ToolCallResult> {
620  if (!(await isShipGateOn($, options).catch(() => true))) return proceed()
621  let isPublish = false
622  try {
623    isPublish = plan(command, undefined).publishes.length > 0
624  } catch {
625    isPublish = false
626  }
627  return isPublish ? { deny: publishFailedDeny } : proceed()
628}
629
630function guardCommandFailed($: EngineInterface, options: PluginOptions, command: string, failure: { message?: string; called: boolean }, proceed: () => Promise<ToolCallResult>): Promise<ToolCallResult> {
631  return guardShellFailed($, options, command, failure, () => (failure.called ? proceed() : shipFailed($, options, command, proceed)))
632}
633
634// ---- Prompt Queue -----------------------------------------------------------------
635// The rules and the text are in queue.ts. The lock, the state and the sending are here.
636
637const QUEUE_OFF_TEXT = 'mod-pack: prompt-queue is OFF. Nothing is queued and nothing is sent. Turn it on with /mods on prompt-queue.'
638
639// `/q`. It runs one at a time with the mod's own callbacks (`isSerial`), so a prompt added in the
640// same moment as a turn ends is neither lost nor sent twice. When the command starts a send (`/q`
641// with no turn running), the lock is taken in the same moment as the decision, as `dispatch` does.
642async function queueCommand($: EngineInterface, options: PluginOptions, args: string): Promise<{ text: string }> {
643  if (!isFeatureOn(promptQueue, parseOverrides(await $.store.get(STORE_KEY)), options)) return { text: QUEUE_OFF_TEXT }
644  return inOrder(async () => {
645    const states: Record<string, unknown> = await read($, featureStates)
646    const before = states[promptQueue.id] as Queue | undefined
647    const result = runQ(args, before, busyWith)
648    const owner = result.send !== undefined ? beginSubmit() : undefined
649    if (result.send !== undefined && !owner) return { text: 'Prompt Queue: nothing was sent, because a compaction or another send is running. Nothing changed.' }
650    try {
651      const state = result.state ?? {}
652      if (result.state !== before) await update($, featureStates, all => ({ ...all, [promptQueue.id]: state }))
653    } catch (error) {
654      if (owner) releaseSubmit(owner)
655      throw error
656    }
657    if (owner && result.send !== undefined) {
658      // The engine refuses `$.prompt.submit` from inside a command.run hook ("it would wait on the
659      // turn this hook is holding"; found by the kit's host check). A timer of 0 ms sends it from an
660      // event of its own, right after this hook has answered.
661      const text = result.send
662      $.clock.after(0, () => void runSubmit($, options, promptQueue, text, owner))
663    }
664    $.ui.invalidate('ui.render')
665    return { text: result.text }
666  })
667}
668
669// ---- Snake ------------------------------------------------------------------------
670// The rules are in snake-game.ts, the drawing in snake.tsx. The engine calls are here: the pane, the
671// timer, the pause and the resume, the command and the high score.
672//
673// The game has its OWN state key (`snakeGame`), not an entry of `featureStates`: it is written about
674// 7 times a second while it runs, and every reader of `featureStates` (the band compositor) would be
675// drawn again each time. Only the pane reads `snakeGame`, so only the pane is redrawn: `$.state.set`
676// draws its readers, and nobody calls `$.ui.invalidate`.
677//
678// The tick is 150 ms: about 7 moves and 7 redraws a second. The engine folds redraws above 10 a second
679// (30 for the pane that is shown), so every tick draws. A faster tick would be a faster game, not a
680// smoother one. The timer is plain module state: a hot reload drops it with the old environment, and
681// the game then waits (state `running`, no timer) until the next event that arms it: `session.start`,
682// `turn.start` or a key press. No timer is left behind: it is cancelled when the pane closes, when the
683// game stops running, when the session ends and when the mod is turned off.
684
685const snakeGame = atom({ plugin: 'mod-pack', key: 'snake' } as const, null as SnakeGame | null)
686
687const SNAKE_PANE = 'snake'
688const SNAKE_TICK_MS = 150
689// The high score. Best effort: a store that cannot be read or written gives 0 and a lost score.
690const SNAKE_BEST_KEY = 'mod-pack/snake-best'
691const SNAKE_OFF_TEXT = 'mod-pack: snake is OFF. Turn it on with /mods on snake.'
692const SNAKE_NO_TERMINAL_TEXT = 'Snake draws in a terminal pane only, and this session has no terminal. Nothing was opened.'
693
694let snakeTimer: { cancel: () => void } | undefined
695function stopSnake() {
696  snakeTimer?.cancel()
697  snakeTimer = undefined
698}
699
700// How many Blast Radius questions are open now. The game is paused while there is one or more.
701let questionsOpen = 0
702
703// Read at call time, so `/mods off snake` applies at once, with no reload.
704async function isSnakeOn($: EngineInterface, options: PluginOptions) {
705  return isFeatureOn(snake, parseOverrides(await $.store.get(STORE_KEY)), options)
706}
707
708// Starts the timer when the game is running and no timer exists. The check and the start follow each
709// other with no wait between, so two calls cannot start two timers.
710async function armSnake($: EngineInterface, options: PluginOptions) {
711  const game = await read($, snakeGame).catch(() => null)
712  if (game?.status !== 'running' || snakeTimer) return
713  snakeTimer = $.clock.every(SNAKE_TICK_MS, () => void snakeTick($, options))
714}
715
716// One move. It stops its own timer when the game is no longer running. When the game ends, the high
717// score is kept (best effort). It never throws.
718async function snakeTick($: EngineInterface, options: PluginOptions) {
719  try {
720    if (!(await isSnakeOn($, options))) return stopSnake()
721    const before = await read($, snakeGame)
722    if (before?.status !== 'running') return stopSnake()
723    const after = await update($, snakeGame, game => (game?.status === 'running' ? step(game) : game))
724    if (after?.status !== 'running') stopSnake()
725    if (after?.status === 'over' && after.best > 0) await $.store.set(SNAKE_BEST_KEY, after.best).catch(() => undefined)
726  } catch (error) {
727    $.ui.log(`mod-pack: snake tick failed: ${String(error)}`)
728  }
729}
730
731// Applies one change to the game (a key, a pause, a resume), then starts or stops the timer to match.
732// `update` reads the game again at the moment of the write, so a key and a tick that come together both
733// land. A mod that is OFF changes nothing. It never throws.
734async function snakeChange($: EngineInterface, options: PluginOptions, change: (game: SnakeGame) => SnakeGame) {
735  try {
736    if (!(await isSnakeOn($, options))) return
737    const game = await update($, snakeGame, current => (current ? change(current) : current))
738    if (game?.status === 'running') await armSnake($, options)
739    else stopSnake()
740  } catch (error) {
741    $.ui.log(`mod-pack: snake failed: ${String(error)}`)
742  }
743}
744
745// A Blast Radius question opens or closes. The first one opened pauses a running game, and the last one
746// closed resumes the game that it paused. It never throws, so the dialog is always shown.
747async function holdSnake($: EngineInterface, options: PluginOptions, isOpening: boolean) {
748  questionsOpen = isOpening ? questionsOpen + 1 : Math.max(0, questionsOpen - 1)
749  if (isOpening ? questionsOpen !== 1 : questionsOpen !== 0) return
750  await snakeChange($, options, game => (isOpening ? pauseFor(game, 'question') : resumeFrom(game, 'question')))
751}
752
753// The pane closed (the person's Esc or close mark, `/snake`, or `/mods off snake`): the timer stops and
754// the game waits, so a later turn does not start it with nothing to draw it.
755async function snakeClosed($: EngineInterface) {
756  stopSnake()
757  try {
758    await update($, snakeGame, game => (game ? halt(game) : game))
759  } catch (error) {
760    $.ui.log(`mod-pack: snake close failed: ${String(error)}`)
761  }
762}
763
764// `/snake`. It opens the pane, or closes it when it is open. The person's command seats a pane at any
765// width (the declared `$.ui.open`), but the board needs MIN_COLUMNS: a narrower terminal is told so,
766// and the pane itself says it when it is docked narrower than the board. A session with no terminal
767// gets a text answer: the Pane component is raised on every surface, but only the terminal draws it here.
768async function snakeCommand($: EngineInterface, options: PluginOptions, columns: number): Promise<{ text: string }> {
769  if (!(await isSnakeOn($, options))) return { text: SNAKE_OFF_TEXT }
770  const hasTerminal = await $.session.surfaces().then(surfaces => surfaces.includes('terminal'), () => false)
771  if (!hasTerminal) return { text: SNAKE_NO_TERMINAL_TEXT }
772
773  const panes = await $.ui.panes().catch(() => [])
774  if (panes.some(pane => pane.id === SNAKE_PANE)) {
775    await $.ui.close({ id: SNAKE_PANE }).catch(error => $.ui.log(`mod-pack: snake could not close: ${String(error)}`))
776    return { text: 'Snake closed. /snake opens it again, with the game where you left it.' }
777  }
778  if (columns < MIN_COLUMNS) return { text: `Snake needs a terminal at least ${MIN_COLUMNS} columns wide. This one is ${columns}.` }
779
780  const best = parseBest(await $.store.get(SNAKE_BEST_KEY).catch(() => undefined))
781  const now = await $.clock.now().catch(() => NaN)
782  await update($, snakeGame, game => (game ? (game.best >= best ? game : { ...game, best }) : newGame(Number.isFinite(now) ? now : 1, best)))
783
784  try {
785    const opened = await $.ui.open({ id: SNAKE_PANE, title: 'Snake', focus: true, closeOnEscape: true, rows: PANE_ROWS })
786    if (!opened.isPlaced) return { text: `Snake could not be shown now: ${opened.reason}` }
787  } catch (error) {
788    return { text: `Snake could not be opened: ${String(error).slice(0, 120)}` }
789  }
790  return { text: 'Snake opened. Press p to play. The keys w a s d p r work while the pane has the keyboard (click it, or ctrl+x then Tab). Esc or /snake closes it.' }
791}
792
793export const register: Register = (on, options) => {
794  on('session.start', async ($, e, next) => {
795    await $.command
796      .register({ name: 'mods', description: 'List the mod-pack features and turn them on or off.', argumentHint: '[on|off|toggle <id> | sound on|off | reset]' })
797      .catch(error => $.ui.log(`mod-pack: could not register /mods: ${String(error)}`))
798    // `immediate`: /q must run while a turn is in flight. That is the point of a queue.
799    await $.command
800      .register({ name: 'q', description: 'Queue follow-up prompts. They send one at a time when a turn ends.', argumentHint: '<text> | rm <n> | clear | pause | resume', immediate: true })
801      .catch(error => $.ui.log(`mod-pack: could not register /q: ${String(error)}`))
802    // `immediate`: the point of Snake is to open it while Claude works.
803    await $.command
804      .register({ name: 'snake', description: 'Play Snake in a pane. It pauses when Claude finishes and goes on at your next prompt.', immediate: true })
805      .catch(error => $.ui.log(`mod-pack: could not register /snake: ${String(error)}`))
806    await dispatch($, options, (feature, state, ctx) => feature.sessionStart?.(state, { e }, ctx))
807    await armTicker($, options)
808    // A hot reload drops the timer and keeps the game: a running game gets its timer back.
809    await armSnake($, options)
810    return next(e)
811  })
812
813  on('session.end', async ($, e, next) => {
814    stopTicker()
815    cancelAsk()
816    releaseSubmit()
817    stopSnake()
818    await snakeChange($, options, halt)
819    await dispatch($, options, (feature, state, ctx) => feature.sessionEnd?.(state, { e }, ctx)).catch(() => undefined)
820    return next(e)
821  })
822
823  // Any compaction of the main conversation: the person's /compact, the engine's own
824  // at its threshold, or a mod's. Held as the lock while it runs; the features are told
825  // when it stands. `precompute` installs nothing, and a subagent's own compaction is
826  // not the main conversation's, so neither counts.
827  on('session.compact', async ($, e, next) => {
828    if (e.trigger === 'precompute' || e.agentId !== undefined) return next(e)
829    // Another compaction already holds the lock: this one is not its owner. A model call does not
830    // keep a compaction out: the compaction takes the lock, and the model call must not free it.
831    const isOwner = busyWith !== 'compaction'
832    if (isOwner) busyWith = 'compaction'
833    $.ui.invalidate('ui.render')
834    try {
835      const result = await next(e)
836      if (result.skip === undefined) await afterCompaction($, options)
837      return result
838    } finally {
839      if (isOwner) busyWith = undefined
840      $.ui.invalidate('ui.render')
841    }
842  })
843
844  on('turn.start', async ($, e, next) => {
845    cancelAsk()
846    // The turn of a prompt on its way has started: the prompt is in, the lock is free.
847    releaseSubmit()
848    await dispatch($, options, (feature, state, ctx) => feature.turnStart?.(state, { e }, ctx))
849    await armTicker($, options)
850    // A game that Claude's end paused goes on: the next prompt has started a turn.
851    await snakeChange($, options, game => resumeFrom(game, 'claude'))
852    return next(e)
853  })
854
855  on('turn.complete', async ($, e, next) => {
856    const context = await $.session.usage().then(usage => usage.context, () => undefined)
857    // A mod that spends tokens on a row only the terminal draws asks for this. Unknown counts as no terminal,
858    // and `surfaces` is then undefined, so a list without the terminal and an unreadable list are told apart.
859    const surfaces = await $.session.surfaces().then(list => list, () => undefined)
860    const hasTerminal = surfaces?.includes('terminal') === true
861    await dispatch($, options, (feature, state, ctx) => feature.turnComplete?.(state, { e, context, hasTerminal, surfaces }, ctx))
862    await armTicker($, options)
863    // The main conversation's turn ended: a running game pauses. A subagent's turn is not Claude finishing.
864    if (e.agentId === undefined) await snakeChange($, options, game => pauseFor(game, 'claude'))
865    return next(e)
866  })
867
868  on('command.run', { command: 'mods' }, async ($, e) => {
869    const before = parseOverrides(await $.store.get(STORE_KEY))
870    // What each feature says of its last outcome (`Feature.last`), for the list. Best effort: a state that cannot be read adds none.
871    const states: Record<string, unknown> = await read($, featureStates).catch(() => ({}))
872    const last = Object.fromEntries(FEATURES.flatMap(f => { const text = f.last?.(states[f.id]); return text ? [[f.id, text]] : [] }))
873    const result = runMods(e.args, FEATURES, before, options, last)
874    if (result.isChanged) {
875      await $.store.set(STORE_KEY, result.overrides)
876      // Snake switched off: its pane closes and its timer stops at once.
877      if (isFeatureOn(snake, before, options) && !isFeatureOn(snake, result.overrides, options)) {
878        stopSnake()
879        await $.ui.close({ id: SNAKE_PANE }).catch(() => undefined)
880      }
881      // A queue that is switched off is emptied: prompts that were queued must not send by themselves
882      // later, after /mods on, when the person has forgotten them.
883      if (!isFeatureOn(promptQueue, result.overrides, options)) {
884        await inOrder(() => update($, featureStates, all => Object.fromEntries(Object.entries(all).filter(([id]) => id !== promptQueue.id)) as ModPackFeatureStates))
885      }
886      $.ui.invalidate('ui.render')
887    }
888    return { text: result.text }
889  })
890
891  // Prompt Queue. Matched by command name, so it does not clash with other plugins' command.run hooks.
892  on('command.run', { command: 'q' }, ($, e) => queueCommand($, options, e.args))
893
894  // Snake. Matched by command name, by pane id and by request id, so none of these clash with other plugins.
895  on('command.run', { command: 'snake' }, ($, e) => snakeCommand($, options, e.presentation.columns))
896
897  // The pane closed. A hook that answers without `next` keeps the pane open, so this one calls it.
898  on('ui.close', { id: SNAKE_PANE }, async ($, e, next) => {
899    const result = await next(e)
900    await snakeClosed($)
901    return result
902  })
903
904  // The Snake pane. It calls `next` and keeps what is below, as every `ui.render` hook of the pack does,
905  // although only this plugin draws this pane: another plugin that hooks this pane still shows. Nothing
906  // may be beneath a pane that only this plugin draws, and in the test kit the bottom of the chain then
907  // throws (no implementation for ui.render). So a failing `next` counts as nothing below, and the pane
908  // is drawn all the same. What the real engine answers there was not seen. The hook reads the game, so
909  // a write to the game (a tick, a key) draws the pane again.
910  on('ui.render', { component: 'Pane', requestId: SNAKE_PANE }, async ($, e, next) => {
911    const below = await next(e).catch(() => undefined)
912    if (e.surface !== 'terminal') {
913      const { Box } = $.ui.resolve(e)
914      return below ?? <Box />
915    }
916    const el = $.ui.resolve(e)
917    const { Box } = el
918    const game = await read($, snakeGame)
919    if (!game) return below ?? <Box />
920
921    const actions = {
922      steer: (dir: Dir) => void snakeChange($, options, current => steer(current, dir)),
923      toggle: () => void snakeChange($, options, toggle),
924      restart: () => void snakeChange($, options, restart),
925    }
926    return (
927      <Box flexDirection="column">
928        {below}
929        {snakeView(game, e.props, el, actions)}
930      </Box>
931    )
932  })
933
934  // Blast Radius and Ship Gate: one hook per tool, because guardCommand runs Blast Radius, then Ship Gate.
935  // Matched by tool name, so it does not clash with the tool.call hooks
936  // of other plugins. PowerShell is a tool of its own with the same `command` field.
937  // The @ts-ignore lines: with many MCP servers connected, the engine writes a large
938  // `.claude-plugin/types/claude-code-mcp/` and tsc then stops on a matcher for
939  // `tool.call` with TS2589 (excessively deep). The types inside the hook still check.
940  // @ts-ignore
941  on('tool.call', { tool: 'Bash' }, ($, e, next) => guardCommand($, options, e.command, () => next(e)))
942    .catch(($, e, next) => guardCommandFailed($, options, e.command, { message: next.error.message, called: next.called }, () => next(e)))
943  // @ts-ignore
944  on('tool.call', { tool: 'PowerShell' }, ($, e, next) => guardCommand($, options, e.command, () => next(e)))
945    .catch(($, e, next) => guardCommandFailed($, options, e.command, { message: next.error.message, called: next.called }, () => next(e)))
946
947  // The compositor: the one hook that draws in the band above the prompt.
948  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
949    const below = await next(e)
950    if (e.surface !== 'terminal' || e.props.hasSurvey) return below
951
952    const el = $.ui.resolve(e)
953    const budget = rowBudget(e.props.maxRows)
954    const now = await $.clock.now().catch(() => NaN)
955    const input = { props: e.props, now, options, isCompacting: busyWith === 'compaction' }
956    const actions = { compact: () => void compactNow($, options) }
957    const states: Record<string, unknown> = await read($, featureStates)
958    // The rows that draw, in FEATURES order. A row is 1 line unless the feature says it draws more now
959    // (`bandLines`: Wait What, 2). `layoutRows` (settings.ts) gives out the lines of the budget.
960    const drawn: { id: string; row: RenderElement; asked: number }[] = []
961    for (const { feature } of await active($, options)) {
962      try {
963        const row = feature.band?.(states[feature.id], input, el, actions)
964        if (row) drawn.push({ id: feature.id, row, asked: feature.bandLines?.(states[feature.id], input) ?? 1 })
965      } catch (error) {
966        $.ui.log(`mod-pack: ${feature.id} band failed: ${String(error)}`)
967      }
968    }
969    const rows = layoutRows(drawn, budget)
970    if (rows.length === 0) return below
971
972    const { Box } = el
973    return (
974      <Box flexDirection="column">
975        {below}
976        {rows.map(({ id, row, lines }) => (
977          <Box key={`row-${id}`} height={lines} overflow="hidden">{row}</Box>
978        ))}
979      </Box>
980    )
981  })
982}
983
hooks/blast-radius.ts 363 lines
1// Blast Radius: hold a risky shell command until the person presses Proceed or Cancel.
2//
3// Pure: no `$` here. This file holds the rule table that says whether a command is
4// risky, the planning of the read-only preview, and the text of the dialog. The
5// engine calls (`$.ui.ask`, `$.process.run`, `$.fs.*`) are all in register.tsx,
6// which hands this file plain data and gets plain data back.
7//
8// ponytail: a safety net, not a permission system. This is a regex table over the
9// command text. It does NOT catch: an alias, a script that calls rm, a command
10// built from variables (`rm -rf "$DIR"`, `eval "$cmd"`), or other quoting tricks.
11// It splits on `&&`, `||`, `;`, `|`, `&` and newlines and also inside quotes, so
12// `echo "a; rm -rf x"` is a false positive. That is acceptable. `echo "rm -rf x"`
13// and `grep rm -rf README` are NOT held: a rule matches only at the start of a segment.
14
15import { defineFeature } from './feature'
16
17// The Feature entry. It has no callbacks: the dispatcher reads only its id, text and
18// default, for `/mods` and for the on/off check. The guard itself is the `tool.call`
19// hook in register.tsx. It makes no model call, so it costs no tokens.
20export const blastRadius = defineFeature<never>({
21  id: 'blast-radius',
22  title: 'Blast Radius',
23  about: 'asks Proceed or Cancel before a risky shell command (rm -rf, git reset --hard, git push --force, ...); costs no model tokens',
24  usesModel: false,
25  defaultOn: true,
26})
27
28// ---- The rule table -------------------------------------------------------------
29
30export type PreviewKind = 'status-all' | 'status-worktree' | 'clean' | 'push' | 'branch' | 'stash' | 'paths'
31
32export type Rule = {
33  kind: string
34  title: string
35  // Tested on one segment, after the prefix (sudo, env vars, ...) is removed.
36  regex: RegExp
37  // When this also matches, the segment is NOT held (a dry run, a staged-only restore).
38  unless?: RegExp
39  // How the person is shown what would change.
40  preview: PreviewKind
41}
42
43// Pieces of the patterns.
44const ARGS = String.raw`(?:\s+\S+)*?` // any words between the command and the flag
45const END = String.raw`(?=\s|$)` // the end of a word
46// `git`, then global options (`-C dir`, `-c k=v`, `--no-pager`), then a space.
47const GIT = String.raw`^git(?:\.exe)?(?:\s+(?:-[Cc]\s+\S+|--?[\w-]+(?:=\S+)?))*\s+`
48
49const re = (source: string, flags = '') => new RegExp(source, flags)
50
51export const RULES: readonly Rule[] = [
52  // Delete. `rm -f file` and `rm file` (no recursion) are NOT held: one named file at a time.
53  { kind: 'rm-recursive', title: 'recursive delete (rm -r)', preview: 'paths',
54    regex: re(String.raw`^(?:\S*[\\/])?rm(?:\.exe)?${ARGS}\s+(?:-[fiIrRdv]*[rR][fiIrRdv]*|--recursive)${END}`) },
55  { kind: 'rmdir-windows', title: 'recursive delete (rmdir /s)', preview: 'paths',
56    regex: re(String.raw`^(?:rmdir|rd)${ARGS}\s+/s(?=[\s/]|$)`, 'i') },
57  { kind: 'del-windows', title: 'recursive delete (del /s)', preview: 'paths',
58    regex: re(String.raw`^(?:del|erase)${ARGS}\s+/s(?=[\s/]|$)`, 'i') },
59  { kind: 'remove-item', title: 'recursive delete (Remove-Item -Recurse)', preview: 'paths',
60    regex: re(String.raw`^(?:remove-item|ri|rm|del|erase|rd|rmdir)${ARGS}\s+-r[a-z]*(?=[\s:]|$)`, 'i') },
61
62  // Git: discards work that has no copy.
63  { kind: 'git-reset-hard', title: 'git reset --hard', preview: 'status-all',
64    regex: re(String.raw`${GIT}reset${ARGS}\s+--hard${END}`) },
65  { kind: 'git-clean', title: 'git clean (deletes untracked files)', preview: 'clean',
66    regex: re(String.raw`${GIT}clean${ARGS}\s+(?:-[a-zA-Z]*[fdxX][a-zA-Z]*|--force)${END}`),
67    unless: re(String.raw`\s(?:-[a-zA-Z]*n[a-zA-Z]*|--dry-run)${END}`) },
68  // `--force-with-lease` is held too: it is safer, but it still overwrites remote history.
69  { kind: 'git-push-force', title: 'git push --force (rewrites remote history)', preview: 'push',
70    regex: re(String.raw`${GIT}push${ARGS}\s+(?:-[a-zA-Z]*f[a-zA-Z]*|--force(?:-with-lease|-if-includes)?(?:=\S+)?|\+[^\s+]\S*)${END}`) },
71  { kind: 'git-discard-checkout', title: 'git checkout -- . (discards working changes)', preview: 'status-worktree',
72    regex: re(String.raw`${GIT}checkout${ARGS}\s+(?:--\s+)?(?:\.|:/)${END}`) },
73  { kind: 'git-discard-restore', title: 'git restore . (discards working changes)', preview: 'status-worktree',
74    regex: re(String.raw`${GIT}restore${ARGS}\s+(?:--\s+)?(?:\.|:/)${END}`),
75    // `--staged` alone only unstages. With `--worktree` it also discards.
76    unless: re(String.raw`^(?=.*\s(?:--staged|-S)${END})(?!.*\s(?:--worktree|-W)${END})`) },
77  { kind: 'git-branch-delete', title: 'git branch -D (force-deletes a branch)', preview: 'branch',
78    regex: re(String.raw`${GIT}branch${ARGS}\s+-[a-zA-Z]*D[a-zA-Z]*${END}`) },
79  { kind: 'git-stash-drop', title: 'git stash drop / clear', preview: 'stash',
80    regex: re(String.raw`${GIT}stash\s+(?:drop|clear)${END}`) },
81]
82
83// ---- Classifier -------------------------------------------------------------------
84
85// Where one command ends and the next begins. `$(` and a backtick start a nested command.
86export const SEPARATORS = /&&|\|\||[;|\n\r`]|\$\(|(?<![0-9>&])&(?![&>])/
87
88// What may stand before the real command word: grouping, `!`, VAR=value, sudo, env,
89// xargs, time, a backslash that skips an alias, and the shell wrappers `sh -c '...'`,
90// `eval '...'`, `cmd /c ...`, `powershell -Command '...'`.
91const PREFIX = new RegExp(
92  String.raw`^\s*(?:` +
93    String.raw`[({!]\s*|\\|[A-Za-z_]\w*=\S*\s+|` +
94    String.raw`(?:sudo|doas)(?:\s+(?:-[ughCpUrtT]\s+\S+|-\S+))*\s+|` +
95    String.raw`(?:command|builtin|exec|nohup|time|nice|xargs|env)(?:\s+(?:-[IdEeLnPsaui]\s+\S+|-\S+))*\s+|` +
96    String.raw`(?:(?:ba|z|da)?sh|eval)(?:\s+-c)?\s+["']?|` +
97    String.raw`cmd(?:\.exe)?\s+/[ck]\s+["']?|` +
98    String.raw`(?:powershell|pwsh)(?:\.exe)?(?:\s+-\S+)*?\s+-c(?:ommand)?\s+["']?` +
99    String.raw`)`,
100  'i',
101)
102
103export const stripPrefix = (segment: string) => {
104  let rest = segment
105  while (PREFIX.test(rest)) rest = rest.replace(PREFIX, '')
106  return rest.trim()
107}
108
109export type Risk = {
110  kind: string
111  title: string
112  preview: PreviewKind
113  // The segment as the rule saw it (prefix removed).
114  segment: string
115  // True when an earlier segment of the command changed directory (`cd x && rm -rf y`).
116  // A relative path is then not resolved against the session folder.
117  hasCd: boolean
118}
119
120const CHANGES_DIRECTORY = /^(?:cd|pushd|chdir|set-location|sl)(?:\s|$)/i
121
122// Every risky segment of `command`, in order. An empty list means: not held.
123export const classify = (command: string): Risk[] => {
124  const risks: Risk[] = []
125  let hasCd = false
126  for (const raw of command.split(SEPARATORS)) {
127    const segment = stripPrefix(raw)
128    if (!segment) continue
129    const rule = RULES.find(r => r.regex.test(segment) && !r.unless?.test(segment))
130    if (rule) risks.push({ kind: rule.kind, title: rule.title, preview: rule.preview, segment, hasCd })
131    if (CHANGES_DIRECTORY.test(segment)) hasCd = true
132  }
133  return risks
134}
135
136// ---- Preview plans: what to read, from the segment's own words --------------------
137
138export const MAX_SHOWN = 10 // paths shown in a list
139export const ENTRY_CAP = 1000 // entries counted in a directory, in total
140export const MAX_PATHS = 8 // plain paths looked at for one rm
141export const MAX_RISKS_PREVIEWED = 3
142export const STEP_TIMEOUT_MS = 3000 // for each `git` call
143export const OUTPUT_CAP = 200_000 // characters of git output read
144export const NO_PREVIEW = 'no preview available'
145
146// The `git` the previews run. Read only, argv (no shell). The command's own global
147// options (`-C`, `-c`) are NOT passed on: a `-c core.fsmonitor=...` would run code
148// before the person said yes.
149export const GIT_READ = ['git', '--no-optional-locks', '-c', 'core.fsmonitor=false'] as const
150
151const unquote = (token: string) => token.replace(/^["']|["')]+$/g, '')
152export const words = (segment: string) => (segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(unquote)
153
154// `git <sub> <args...>`. Undefined when global options stand before the subcommand:
155// the previews do not follow `-C` or `--git-dir`.
156export const gitArgs = (segment: string, sub: string): string[] | undefined => {
157  const all = words(segment)
158  return /^git(?:\.exe)?$/.test(all[0] ?? '') && all[1] === sub ? all.slice(2) : undefined
159}
160
161// True when global options (`-C dir`, `-c k=v`) stand between `git` and the subcommand.
162export const hasGitOptions = (segment: string) => !/^git(?:\.exe)?\s+[a-z]/.test(segment)
163
164export const SAFE_NAME = /^[\w.\/-]+$/
165
166// `git clean -fd` -> ['clean', '-n', '-d']: the same flags, and `-n` for a dry run.
167export const cleanDryRun = (segment: string): string[] | undefined => {
168  const args = gitArgs(segment, 'clean')
169  if (!args || args.some(a => /[$%]/.test(a))) return undefined
170  const kept = args.flatMap(a => {
171    if (a === '--force' || a === '--interactive' || a === '--dry-run') return []
172    if (/^-[a-zA-Z]+$/.test(a)) {
173      const letters = a.slice(1).replace(/[fniq]/g, '')
174      return letters ? [`-${letters}`] : []
175    }
176    return [a]
177  })
178  return ['clean', '-n', ...kept]
179}
180
181export type PushPlan = { range?: string; note?: string }
182
183// Which commits does the force push drop? The ones the remote branch has and the
184// local branch has not: `local..remote`.
185export const pushPlan = (segment: string): PushPlan => {
186  const args = gitArgs(segment, 'push')
187  if (!args) return { note: 'the git options of the command are not followed' }
188  const places = args.filter(a => !a.startsWith('-')).map(a => a.replace(/^\+/, ''))
189  if (places.length <= 1) return { range: 'HEAD..@{u}' }
190  const [remote = '', branch = ''] = places
191  if (places.length === 2 && /^[\w.-]+$/.test(remote) && SAFE_NAME.test(branch) && !branch.startsWith('-')) {
192    return { range: `${branch}..${remote}/${branch}` }
193  }
194  return { note: `the target "${places.join(' ')}" is not a plain remote and branch, so commits are not counted` }
195}
196
197// Names after `git branch -D`: at most 3, plain names only.
198export const branchTargets = (segment: string): string[] =>
199  (gitArgs(segment, 'branch') ?? []).filter(a => !a.startsWith('-') && SAFE_NAME.test(a)).slice(0, 3)
200
201export type PathArgs = { paths: string[]; skipped: string[] }
202
203// Flags of Remove-Item that take a value which is not a path.
204const VALUE_FLAGS = /^-(?:include|exclude|filter|credential|stream)$/i
205
206// The plain path arguments of a delete command. A glob, a variable or `~` cannot be
207// resolved here and is listed as skipped.
208export const pathArgs = (segment: string): PathArgs => {
209  const tokens = words(segment).slice(1)
210  const paths: string[] = []
211  const skipped: string[] = []
212  for (let i = 0; i < tokens.length; i++) {
213    const token = tokens[i] ?? ''
214    if (VALUE_FLAGS.test(token)) i++
215    else if (token === '' || token.startsWith('-') || /^\/[a-zA-Z]$/.test(token) || /^\d*[<>]/.test(token)) continue
216    else if (/[*?[\]$%`{}]|^~/.test(token)) skipped.push(token)
217    else paths.push(token)
218  }
219  return { paths, skipped }
220}
221
222const isAbsolute = (path: string) => /^(?:[A-Za-z]:)?[\\/]/.test(path)
223
224// A relative path belongs to the session folder. `cwd` joins with `/`, which Windows accepts too.
225export const resolvePath = (cwd: string, path: string) => (isAbsolute(path) ? path : `${cwd.replace(/[\\/]+$/, '')}/${path}`)
226
227// ---- Preview text -----------------------------------------------------------------
228
229// What a git read returned. `undefined` (not this type): the step could not run or ran out of time.
230export type GitOut = { ok: boolean; text: string; isCut: boolean }
231type Out = GitOut | undefined
232
233const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
234const lines = (out: Out) => (out?.ok ? out.text.split(/\r?\n/).filter(Boolean) : undefined)
235const more = (n: number) => (n > 0 ? [`  ...and ${n} more`] : [])
236const countText = (n: number, isCut: boolean) => (isCut ? `${n}+` : String(n))
237const list = (items: readonly string[]) => [...items.slice(0, MAX_SHOWN).map(i => `  ${i}`), ...more(items.length - MAX_SHOWN)]
238
239export const NO_GIT_PREVIEW = `${NO_PREVIEW} (not a git repository, git is missing, or the git read failed)`
240
241// `git status --porcelain` lines are `XY path`: X the index, Y the work tree.
242// `scope` 'worktree' counts only what `checkout -- .` / `restore .` would discard.
243export const previewStatus = (status: Out, shortstat: Out, scope: 'all' | 'worktree'): string[] => {
244  const all = lines(status)
245  if (!all || !status) return [NO_GIT_PREVIEW]
246  const untracked = all.filter(l => l.startsWith('??')).length
247  const tracked = all.filter(l => !l.startsWith('??') && !l.startsWith('!!') && (scope === 'all' || (l[1] ?? ' ') !== ' '))
248  const out: string[] = []
249  if (tracked.length === 0) {
250    out.push(scope === 'all' ? 'no uncommitted changes to tracked files to lose.' : 'no unstaged changes to tracked files to lose.')
251  } else {
252    out.push(`${countText(tracked.length, status.isCut)} tracked ${tracked.length === 1 ? 'file' : 'files'} with ${scope === 'all' ? 'uncommitted' : 'unstaged'} changes would be overwritten (status, path):`)
253    out.push(...list(tracked))
254    const stat = lines(shortstat)?.[0]
255    if (stat) out.push(`  ${stat.trim()}`)
256  }
257  if (untracked > 0) out.push(`${plural(untracked, 'untracked file')} not touched.`)
258  if (scope === 'all') out.push('A reset to another commit also moves the branch; those commits are not counted here.')
259  return out
260}
261
262export const previewClean = (dryRun: Out): string[] => {
263  const all = lines(dryRun)
264  if (!all || !dryRun) return [NO_GIT_PREVIEW]
265  const paths = all.filter(l => l.startsWith('Would remove ')).map(l => l.slice('Would remove '.length))
266  if (paths.length === 0) return ['git clean -n lists nothing to delete.']
267  return [`${countText(paths.length, dryRun.isCut)} untracked ${paths.length === 1 ? 'path' : 'paths'} would be deleted (git clean -n):`, ...list(paths)]
268}
269
270export type PushInfo = { branch: Out; upstream: Out; count: Out; log: Out; plan: PushPlan }
271
272export const previewPush = ({ branch, upstream, count, log, plan }: PushInfo): string[] => {
273  const name = lines(branch)?.[0]
274  const target = lines(upstream)?.[0]
275  const out: string[] = []
276  if (name) out.push(`current branch: ${name}, upstream: ${target ?? 'none set'}`)
277  const n = Number(lines(count)?.[0])
278  if (plan.range && Number.isInteger(n)) {
279    out.push(
280      n === 0
281        ? 'the remote branch has no commits that your branch lacks (per the last fetch).'
282        : `${plural(n, 'commit')} on the remote would be lost (per the last fetch, ${plan.range}):`,
283    )
284    const commits = lines(log) ?? []
285    if (n > 0) out.push(...list(commits))
286  } else {
287    out.push('remote history may be overwritten; the lost commits could not be counted.')
288  }
289  if (plan.note) out.push(plan.note)
290  return out.length ? out : [NO_GIT_PREVIEW]
291}
292
293export const previewBranch = (items: readonly { name: string; count: Out }[]): string[] => {
294  if (items.length === 0) return [NO_PREVIEW]
295  return items.map(({ name, count }) => {
296    const n = Number(lines(count)?.[0])
297    return Number.isInteger(n)
298      ? `branch ${name}: ${plural(n, 'commit')} not in the current branch${n > 0 ? ' (only the reflog keeps them after the delete)' : ''}`
299      : `branch ${name}: ${NO_PREVIEW}`
300  })
301}
302
303export const previewStash = (stashes: Out): string[] => {
304  const all = lines(stashes)
305  if (!all) return [NO_GIT_PREVIEW]
306  return [`${plural(all.length, 'stash', 'stashes')} exist. "drop" removes the one named (default stash@{0}); "clear" removes all.`, ...list(all)]
307}
308
309// What the engine found for one path.
310export type PathFact =
311  | { path: string; kind: 'missing' | 'file' | 'other' }
312  | { path: string; kind: 'link' }
313  | { path: string; kind: 'dir'; entries: number; isCapped: boolean }
314
315export const previewPaths = (facts: readonly PathFact[], args: PathArgs, hasCd: boolean): string[] => {
316  const out: string[] = []
317  if (hasCd) out.push('an earlier part of the command changes directory, so relative paths are not resolved:')
318  for (const f of facts) {
319    if (f.kind === 'dir') out.push(`${f.path}: directory, ${f.isCapped ? `${ENTRY_CAP}+` : f.entries} ${f.entries === 1 && !f.isCapped ? 'entry' : 'entries'} inside`)
320    else if (f.kind === 'file') out.push(`${f.path}: file`)
321    else if (f.kind === 'link') out.push(`${f.path}: symbolic link (the link is removed, its target is not followed)`)
322    else if (f.kind === 'other') out.push(`${f.path}: exists, not a plain file or directory`)
323    else out.push(`${f.path}: not found`)
324  }
325  if (args.paths.length > facts.length) out.push(`...and ${args.paths.length - facts.length} more paths not looked at`)
326  if (args.skipped.length) out.push(`not previewed (glob or variable): ${args.skipped.slice(0, MAX_SHOWN).join(' ')}`)
327  return out.length ? out : [`${NO_PREVIEW} (no plain path in the command)`]
328}
329
330// ---- The dialog -------------------------------------------------------------------
331
332export const PROCEED = 'Proceed'
333export const CANCEL = 'Cancel'
334export const DIALOG_HEADER = 'Blast Radius' // 12 characters: the most the chip takes
335
336const shorten = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}...` : text)
337
338export const buildQuestion = (command: string, risks: readonly Pick<Risk, 'title'>[], preview: readonly string[]): string =>
339  [
340    `Blast Radius holds this command: ${risks.length ? risks.map(r => r.title).join('; ') : 'it could not be checked'}.`,
341    '',
342    `  ${shorten(command, 400)}`,
343    '',
344    'What would change (a best-effort, read-only check):',
345    ...preview.map(l => `  ${l}`),
346    '',
347    'Run it now?',
348  ].join('\n')
349
350// Only the exact label Proceed lets the command run. Free text typed under "Other",
351// an empty answer and every other label count as Cancel. `answer` is undefined when
352// `$.ui.ask` rejected: the dialog was dismissed, or nobody could be asked (`claude -p`).
353export const isProceed = (answer: string | undefined) => answer === PROCEED
354
355// What Claude reads when the command is refused.
356export const denyReason = (answer: string | undefined, kinds: readonly string[]): string => {
357  const what = kinds.join(', ') || 'unchecked'
358  const tail = 'It did not run. Do not retry the same command unprompted; ask the person what they want instead.'
359  return answer === undefined
360    ? `Blast Radius: no confirmation was given for this command (${what}): the dialog was dismissed, or nobody could be asked (a non-interactive run). ${tail}`
361    : `Blast Radius: the person cancelled this command (${what}). ${tail}`
362}
363
hooks/cache-keeper.tsx 95 lines
1// Cache Keeper: a countdown of how long the prompt cache stays warm after the last
2// response, in one row above the prompt, with a button that compacts the conversation.
3// The row says warm (time left), cools in N minutes (last 5 minutes, one toast), or
4// cold (the next prompt re-reads the whole context uncached).
5//
6// Pure: it holds no `$`. The dispatcher (register.tsx) passes in the clock, the
7// context size and the setting, keeps the state this returns, shows the toast, and
8// runs the compaction that the button asks for.
9//
10// Decisions:
11//   - The clock restarts at the `turn.complete` of the MAIN conversation. A
12//     subagent's turn does not restart it: a subagent has its own cache and leaves
13//     the main conversation's cache as it was (code.claude.com prompt-caching, "Subagents and the cache").
14//   - An interrupted or failed turn restarts it only when it reported usage (a model
15//     response was counted). Otherwise the earlier time stays: that is the safe side.
16//   - While a turn runs there is no row: each request of the turn refreshes the cache.
17//   - A compaction, and /clear, remove the row until the next response: the old
18//     cache entry no longer matches the new, shorter history.
19//   - No sound. Plugin sound plays only on macOS, and the only sound in the pack
20//     (thunder) means a full context, which is a different warning.
21
22import { defineFeature } from './feature'
23import { clockText, coldText, coolsInText, readout, tokensText, ttlMs, WARN_MS, warnText } from './cache-clock'
24import type { CacheClock } from './cache-clock'
25
26export const cacheKeeper = defineFeature<CacheClock>({
27  id: 'cache-keeper',
28  title: 'Cache Keeper',
29  about: 'countdown of how long the prompt cache stays warm, with a compact button; costs no model tokens; lifetime 60 min by default (a subscription), set cacheTtlMinutes to 5 on an API key',
30  usesModel: false,
31  defaultOn: true,
32
33  turnStart: state => ({ state: { ...state, isWorking: true } }),
34
35  turnComplete(state, { e, context }, ctx) {
36    if (e.agentId !== undefined) return undefined
37
38    // A response was counted when the turn answered or was refused, or it reported usage.
39    const responded = e.reason === 'answer' || e.reason === 'refusal' || e.usage !== undefined
40    if (!responded || !Number.isFinite(ctx.now)) return { state: { ...state, isWorking: false } }
41
42    return { state: { at: ctx.now, tokens: context?.tokens, isWorking: false, isWarned: false } }
43  },
44
45  // The conversation cache is gone after /clear or an exit.
46  sessionEnd: () => ({ state: {} }),
47
48  // The history is new and shorter. The next response makes the next entry.
49  compacted: () => ({ state: {} }),
50
51  // Once a minute. One toast per response, when the cache enters its last 5 minutes.
52  // A lifetime of 5 minutes or less has no warm part, so it gets no toast.
53  tick(state, ctx) {
54    const lifetime = ttlMs(ctx.options.cacheTtlMinutes)
55    const now = readout(state, ctx.now, lifetime)
56    if (!state || state.isWorking || state.isWarned || now.kind !== 'cooling' || lifetime <= WARN_MS) return undefined
57    return { state: { ...state, isWarned: true }, toast: warnText(now.leftMs) }
58  },
59
60  band(state, { props, now, options, isCompacting }, el, actions) {
61    const { Box, Button, Text } = el
62    if (isCompacting) return <Text color="yellow">⏱ compacting…</Text>
63    if (props.isWorking) return null
64
65    const r = readout(state, now, ttlMs(options.cacheTtlMinutes))
66    if (r.kind === 'none') return null
67
68    const compact = <Button key="compact" label="compact" onPress={actions.compact} />
69
70    if (r.kind === 'warm') {
71      return (
72        <Box>
73          <Text>⏱ cache warm {clockText(r.leftMs)} left{tokensText(r.tokens)} </Text>
74          {compact}
75        </Box>
76      )
77    }
78    if (r.kind === 'cooling') {
79      return (
80        <Box>
81          <Text color="yellow" bold>⏱ cache cools in {coolsInText(r.leftMs)}</Text>
82          <Text>{tokensText(r.tokens)} </Text>
83          {compact}
84        </Box>
85      )
86    }
87    return (
88      <Box>
89        <Text color="red">⏱ {coldText(r.tokens)} </Text>
90        {compact}
91      </Box>
92    )
93  },
94})
95
hooks/feature.ts 150 lines
1// The contract between the dispatcher (register.tsx) and one mod in the pack.
2//
3// A feature is a plain object of PURE functions. It never receives `$`.
4// Why: `claude plugin validate` refuses a `$` that is passed to an imported
5// function or to an object's method ("$ itself is passed as an argument"), so
6// every `$` call has to be written in register.tsx. The dispatcher therefore
7// reads the engine, hands the feature plain data, and applies what the feature
8// returns: its new state, and a sound to play.
9//
10// The dispatcher calls a callback only while the feature is ON, and an error
11// in one feature never reaches another.
12
13import type { Elements, ModelEffort, PluginOptions, RenderElement, RenderPropsOf, SessionContextUsage, SessionEndInput, SessionStartInput, TurnCompleteInput, TurnStartInput } from 'claude-code'
14
15// The terminal's element table: Box, Text, Button, ...
16export type Terminal = Elements['terminal']
17
18// Who holds the shared automation lock (see register.tsx): a compaction, a model call of a mod
19// (Wait What), or a prompt that the Prompt Queue is sending. Nobody: the lock is free.
20export type Lock = 'compaction' | 'model-call' | 'prompt-submit'
21
22// What the dispatcher tells a feature about the moment of the call.
23export type FeatureContext = {
24  // True only when ALL of these hold: the global `sound` setting is ON, this
25  // feature is ON, and this feature declares `hasSound`.
26  isSoundAllowed: boolean
27  // The engine's clock at the moment of the call, in ms. NaN when it could not be read.
28  now: number
29  // The plugin's settings (userConfig). A mod reads its own number settings here.
30  options: PluginOptions
31  // True while the shared automation lock is held (a compaction, a model call or a queued prompt
32  // on its way, see register.tsx). Read at the moment of the call. A mod that starts an automatic
33  // action must not start it while this is true, unless it knows that the holder does not matter.
34  isBusy: boolean
35  // Who holds the lock, in the same moment as `isBusy`. Undefined when it is free. The Prompt Queue
36  // reads it: it ignores a model call (the next prompt cancels it) but not a compaction.
37  lock: Lock | undefined
38}
39
40// One model call a feature asks the dispatcher to make (`Step.ask`). Plain data: the
41// dispatcher calls `$.model.complete`, detached, and hands the result back to
42// `Feature.modelDone`. The dispatcher takes the lock for the call and cancels it at the
43// next `turn.start` or `session.end`.
44export type ModelAsk = {
45  // Names the call. `modelDone` gets it back, so the feature can drop a late result.
46  turnId: string
47  // An alias (`haiku`) or a model id.
48  model: string
49  system: string
50  prompt: string
51  maxTokens: number
52  timeoutMs: number
53  effort?: ModelEffort
54}
55
56// What a model call came to. The text of a reply, or nothing, and then why: `api-error` (with the
57// HTTP `status`, null when no response came, and the `error` kind that Claude Code names),
58// `empty-reply` (the model sent no text), `aborted` (cut by the time limit), or `rejected` (Claude
59// Code refused to send the request at all; `error` is the short text of the refusal). These are the
60// arms of `ModelCompleteResult` plus `rejected`, which is a rejection of the call, not a result.
61export type ModelReply =
62  | { isAnswered: true; text: string }
63  | { isAnswered: false; reason: 'api-error' | 'empty-reply' | 'aborted' | 'rejected'; status?: number | null; error?: string }
64
65// What a callback returns. Both fields are optional; returning nothing changes nothing.
66export type Step<S> = {
67  // The feature's new state. The dispatcher keeps it in $.state under the
68  // feature's id, so it survives a hot reload.
69  state?: S
70  // A sound file of this plugin, relative to the plugin folder ('assets/x.wav').
71  // The dispatcher plays it only when `isSoundAllowed` is true.
72  sound?: string
73  // A line for `$.ui.toast`. The dispatcher shows it.
74  toast?: string
75  // A line for `$.ui.log`, the transcript log. The dispatcher writes it as `mod-pack: <id>: <log>`.
76  // For a fact worth finding later when the band is clipped, not for every turn.
77  log?: string
78  // A model call to make. Only when `ctx.isBusy` was false: the dispatcher takes the lock in
79  // the same moment, with no wait in between. The result comes to `modelDone`.
80  ask?: ModelAsk
81  // A prompt to send as the person's own words (`$.prompt.submit`, detached). Only when `ctx.lock`
82  // allowed it: the dispatcher takes the lock in the same moment, with no wait in between. If Claude
83  // Code refuses the prompt, the feature hears of it in `submitFailed`.
84  submit?: string
85}
86
87export type Feature<S = unknown> = {
88  // Short kebab-case name. Used by `/mods on <id>`. The userConfig key is the
89  // camelCase of it (`token-weather` -> `tokenWeather`).
90  id: string
91  title: string
92  // One line for the `/mods` list.
93  about: string
94  // True when the feature spends model tokens (shown in `/mods`).
95  usesModel?: boolean
96  // True when the feature can play a sound (shown in `/mods`).
97  hasSound?: boolean
98  // The state when neither the user's setting nor a /mods override says.
99  defaultOn: boolean
100  // True when a command of the mod also changes its state (`/q`). The dispatcher then runs this
101  // mod's callbacks one at a time with that command, so that neither overwrites the other.
102  isSerial?: true
103
104  // `state` is the feature's own earlier state, undefined before its first step.
105  // `context` is the engine's reading of the context window; undefined when it could not be read.
106  // `hasTerminal` is true when the session draws on a terminal now. A mod that spends tokens for
107  // a row that only the terminal draws must check it. `surfaces` is the list that it was read from:
108  // undefined when `$.session.surfaces()` rejected (then `hasTerminal` is false as well), so that a
109  // list with no terminal in it and a list that could not be read are told apart.
110  turnComplete?: (state: S | undefined, input: { e: TurnCompleteInput; context: SessionContextUsage | undefined; hasTerminal: boolean; surfaces?: readonly string[] }, ctx: FeatureContext) => Step<S> | undefined
111  turnStart?: (state: S | undefined, input: { e: TurnStartInput }, ctx: FeatureContext) => Step<S> | undefined
112  sessionStart?: (state: S | undefined, input: { e: SessionStartInput }, ctx: FeatureContext) => Step<S> | undefined
113  // The session ends: exit, or /clear (which raises no `session.start` after it).
114  sessionEnd?: (state: S | undefined, input: { e: SessionEndInput }, ctx: FeatureContext) => Step<S> | undefined
115  // The main conversation was compacted (by the person, by the engine, or by a mod).
116  compacted?: (state: S | undefined, ctx: FeatureContext) => Step<S> | undefined
117  // Once a minute while a feature with a `tick` is ON. The dispatcher redraws the band after it.
118  tick?: (state: S | undefined, ctx: FeatureContext) => Step<S> | undefined
119  // The model call that `Step.ask` started has ended. `state` is read again now, so a feature
120  // compares `turnId` with its own state to drop a result that came late.
121  modelDone?: (state: S | undefined, input: { turnId: string; reply: ModelReply }, ctx: FeatureContext) => Step<S> | undefined
122  // The prompt that `Step.submit` sent was refused (a hook dropped it, or the call failed). `why` is short text.
123  submitFailed?: (state: S | undefined, input: { text: string; why: string }, ctx: FeatureContext) => Step<S> | undefined
124
125  // One row for the band above the prompt, or null to draw nothing now.
126  // The compositor clips the row to `bandLines` terminal rows (1 when the feature has no
127  // `bandLines`) and counts it as that many rows against its budget. `el` is the terminal's
128  // element table.
129  // Never bind a digit hotkey here (see README, "How mods share the band").
130  // `e.now` is the clock (NaN when unreadable) and `e.isCompacting` is the shared
131  // automation lock. `actions` are plain callbacks the dispatcher built, for a Button's `onPress`.
132  band?: (state: S | undefined, e: BandInput, el: Terminal, actions: BandActions) => RenderElement | null
133  // How many terminal rows `band` draws for this state now: 1 or 2. Absent: 1. The compositor
134  // asks it only after `band` returned a row. When fewer rows are left in the budget, the row
135  // is clipped to the rows left (its first lines show).
136  bandLines?: (state: S | undefined, e: BandInput) => number
137  // One short line for `/mods`, shown under the feature while it is ON: what its last outcome was,
138  // or undefined for nothing to say. Needed when the band cannot show it (no room, or nothing drawn).
139  last?: (state: S | undefined) => string | undefined
140}
141
142export type BandInput = { props: RenderPropsOf['AbovePrompt']; now: number; options: PluginOptions; isCompacting: boolean }
143
144// What a band row may trigger. Each one is fire-and-forget: it returns at once.
145export type BandActions = { compact: () => void }
146
147// Types the feature's state `S`, then stores it with the state erased, so that
148// FEATURES can hold features of different state types in one list.
149export const defineFeature = <S>(feature: Feature<S>): Feature => feature as unknown as Feature
150
hooks/mods-command.ts 64 lines
1// The /mods command as a pure function: text in, text and new overrides out.
2// register.tsx stores the overrides when `isChanged` is true.
3
4import type { PluginOptions } from 'claude-code'
5
6import type { Feature } from './feature'
7import { isFeatureOn, isSoundOn, NO_OVERRIDES, type Overrides } from './settings'
8
9export type ModsResult = { text: string; overrides: Overrides; isChanged: boolean }
10
11type Listed = Pick<Feature, 'id' | 'title' | 'about' | 'usesModel' | 'hasSound' | 'defaultOn'>
12
13const USAGE = [
14  'Usage:',
15  '  /mods                     list the features',
16  '  /mods on <id>             turn a feature on',
17  '  /mods off <id>            turn a feature off',
18  '  /mods toggle <id>         flip a feature',
19  '  /mods sound on|off        the global sound switch',
20  '  /mods reset               drop every /mods choice and use the settings again',
21].join('\n')
22
23// `last`: what a feature says of its last outcome, by feature id (`Feature.last`). The list shows it
24// under a feature that is ON. The default is no feature saying anything.
25export const runMods = (args: string, features: readonly Listed[], overrides: Overrides, options: PluginOptions, last: Readonly<Record<string, string>> = {}): ModsResult => {
26  const [verb = '', arg = '', ...rest] = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
27  const unchanged = (text: string): ModsResult => ({ text, overrides, isChanged: false })
28  const changed = (text: string, next: Overrides): ModsResult => ({ text, overrides: next, isChanged: true })
29  const known = features.map(f => f.id).join(', ')
30
31  const list = () => {
32    const lines = features.map(f => {
33      const flags = [f.usesModel ? 'uses model tokens' : '', f.hasSound ? 'plays sound' : ''].filter(Boolean)
34      const isOn = isFeatureOn(f, overrides, options)
35      const line = `  ${isOn ? 'ON ' : 'OFF'}  ${f.id}  ${f.title}: ${f.about}${flags.length ? ` [${flags.join(', ')}]` : ''}`
36      return isOn && last[f.id] ? `${line}\n    last: ${last[f.id]}` : line
37    })
38    return [`mod-pack features (sound: ${isSoundOn(overrides, options) ? 'ON' : 'OFF'}):`, ...lines, '', 'Type /mods help for the commands.'].join('\n')
39  }
40
41  if (verb === '' || verb === 'list') return arg || rest.length ? unchanged(USAGE) : unchanged(list())
42  if (verb === 'help') return unchanged(USAGE)
43
44  if (verb === 'reset') {
45    return arg ? unchanged(USAGE) : changed('mod-pack: every /mods choice is cleared. The settings apply again.', NO_OVERRIDES)
46  }
47
48  if (verb === 'sound') {
49    if ((arg !== 'on' && arg !== 'off') || rest.length) return unchanged(USAGE)
50    const note = arg === 'on' ? ' Only features that are ON and can play sound will play one. Claude Code plays plugin sound only where it has a player (macOS).' : ''
51    return changed(`mod-pack: sound is ${arg.toUpperCase()}.${note}`, { ...overrides, sound: arg === 'on' })
52  }
53
54  if (verb === 'on' || verb === 'off' || verb === 'toggle') {
55    if (!arg || rest.length) return unchanged(USAGE)
56    const feature = features.find(f => f.id === arg)
57    if (!feature) return unchanged(`mod-pack: no feature named "${arg}". Known: ${known}.`)
58    const isOn = verb === 'toggle' ? !isFeatureOn(feature, overrides, options) : verb === 'on'
59    return changed(`mod-pack: ${feature.id} is ${isOn ? 'ON' : 'OFF'}.`, { ...overrides, features: { ...overrides.features, [feature.id]: isOn } })
60  }
61
62  return unchanged(`mod-pack: "${verb}" is not a command.\n${USAGE}`)
63}
64
hooks/prompt-queue.tsx 63 lines
1// Prompt Queue: type `/q <text>` while Claude works to stack follow-up prompts. They are sent one at
2// a time, each when the turn before it ends. One row above the prompt shows what waits.
3//
4// It costs no model tokens of its own. Each prompt it sends is a normal turn of your session, on your
5// plan, with your permissions: read what you queue as if you had typed it at that moment.
6//
7// Pure: it holds no `$`. The dispatcher (register.tsx) tells it about each turn and the lock, sends
8// the prompt it asks for (`$.prompt.submit`), keeps the state, and serves `/q` (hooks/queue.ts).
9//
10// Decisions:
11//   - Only the main conversation's turn sends. A subagent's turn never does.
12//   - Only a turn that ended with an answer sends. An interrupted turn, an error or a refusal PAUSES
13//     the queue (the row says why) until `/q resume`.
14//   - The lock (see register.tsx): a compaction in progress holds the queue back, and so does a prompt
15//     it sent that has not started its turn yet. A Wait What model call does not: the next prompt cancels it.
16//   - `/q` with no turn running sends the first prompt at once: nothing else would ever send it.
17//   - `/clear` and the end of the session empty the queue. `/mods off prompt-queue` empties it too.
18//   - The row draws while a turn runs (that is when the queue matters). No Button, no hotkey.
19
20import { defineFeature } from './feature'
21import { onSendFailed, onTurnComplete, onTurnStart, pauseText, queueRow } from './queue'
22import type { Queue } from './queue'
23
24export const promptQueue = defineFeature<Queue>({
25  id: 'prompt-queue',
26  title: 'Prompt Queue',
27  about: 'type /q <text> while Claude works to queue follow-up prompts; they send one at a time when a turn ends; costs no model tokens itself; queued prompts run with your permissions',
28  usesModel: false,
29  defaultOn: true,
30  isSerial: true,
31
32  turnStart: state => ({ state: onTurnStart(state) }),
33
34  turnComplete(state, { e }, ctx) {
35    // A subagent's turn is not the person's turn.
36    if (e.agentId !== undefined) return undefined
37
38    const done = onTurnComplete(state, { reason: e.reason, turnId: e.turnId }, ctx.lock)
39    const isNewPause = done.state.pause !== undefined && state?.pause === undefined
40    return {
41      state: done.state,
42      ...(done.send !== undefined ? { submit: done.send } : {}),
43      ...(isNewPause ? { toast: `Prompt Queue paused: ${pauseText(done.state.pause!)}. Type /q resume to send.` } : {}),
44    }
45  },
46
47  // The prompt just sent was refused: it goes back to the top, and the queue pauses.
48  submitFailed: (state, { text, why }) => ({
49    state: onSendFailed(state, text),
50    toast: `Prompt Queue: could not send a queued prompt (${why}). It is back at the top and the queue is paused. Type /q resume to try again.`,
51  }),
52
53  // /clear or an exit.
54  sessionEnd: () => ({ state: {} }),
55
56  band(state, { props }, el) {
57    const row = queueRow(state, props.bodyColumns)
58    if (!row) return null
59    const { Text } = el
60    return row.isPaused ? <Text color="yellow">{row.text}</Text> : <Text>{row.text}</Text>
61  },
62})
63
hooks/queue.ts 192 lines
1// Prompt Queue, the pure part: the `/q` parser, the queue's rules, and the band row's text.
2// No engine calls here. The dispatcher (register.tsx) reads the lock, calls these, keeps the
3// state they return, and does the sending (`$.prompt.submit`).
4//
5// A queued prompt is plain text. It is sent as the person's own words when a turn ends.
6
7import type { Lock } from './feature'
8
9// The most prompts in the queue, and the most characters in one. A longer prompt is refused,
10// not cut: a cut prompt would say something else than the person wrote.
11export const MAX_ITEMS = 20
12export const MAX_CHARS = 2000
13
14// Why the queue does not send. `user`: `/q pause`. `aborted`, `error`, `refusal`: the last turn did
15// not end with an answer. `send-failed`: Claude Code refused the last prompt we sent.
16export type PauseWhy = 'user' | 'aborted' | 'error' | 'refusal' | 'send-failed'
17
18// What the dispatcher keeps in $.state for this mod.
19export type Queue = {
20  // The prompts waiting, first to send first.
21  items?: string[]
22  // Set: the queue sends nothing until `/q resume` (or `/q clear`).
23  pause?: PauseWhy
24  // True from `turn.start` to `turn.complete` of the main conversation. A prompt typed with `/q`
25  // while no turn runs is sent at once; while one runs, it waits for the end of the turn.
26  isWorking?: boolean
27  // The `turn.complete` that last sent a prompt. The same turn never sends twice.
28  sentFor?: string
29}
30
31export type Command =
32  | { kind: 'add'; text: string }
33  | { kind: 'list' }
34  | { kind: 'rm'; n: number }
35  | { kind: 'clear' }
36  | { kind: 'pause' }
37  | { kind: 'resume' }
38
39// Only the exact forms are commands: the whole argument is `list`, `clear`, `pause` or `resume`,
40// or it is `rm <number>`, or it starts with `add `. Anything else is a prompt to queue. So
41// `/q rm the old files` queues that sentence, and `/q add pause` queues the word pause.
42export const parseQ = (args: string): Command => {
43  const text = args.trim()
44  const word = text.toLowerCase()
45  if (word === '' || word === 'list') return { kind: 'list' }
46  if (word === 'clear' || word === 'pause' || word === 'resume') return { kind: word }
47  const rm = /^rm\s+(\d+)$/i.exec(text)
48  if (rm) return { kind: 'rm', n: Number(rm[1]) }
49  if (word === 'add') return { kind: 'add', text: '' }
50  const add = /^add\s+([\s\S]+)$/i.exec(text)
51  if (add) return { kind: 'add', text: (add[1] as string).trim() }
52  return { kind: 'add', text }
53}
54
55// A prompt as one short line for a list or the row: control characters (an escape sequence in a
56// pasted text) become spaces, spaces are folded, a long one is cut with `…`.
57export const oneLine = (text: string, max: number): string => {
58  const flat = text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim()
59  const chars = [...flat]
60  return chars.length > max ? `${chars.slice(0, Math.max(0, max - 1)).join('')}…` : flat
61}
62
63const clip = (text: string, width: number) => {
64  const chars = [...text]
65  return chars.length > width ? `${chars.slice(0, Math.max(0, width - 1)).join('')}…` : text
66}
67
68export const pauseText = (why: PauseWhy): string =>
69  ({
70    user: 'paused with /q pause',
71    aborted: 'you interrupted the turn',
72    error: 'the last turn ended with an error',
73    refusal: 'the model refused the last turn',
74    'send-failed': 'Claude Code did not accept the last prompt',
75  })[why]
76
77// The queue may send while nothing holds the lock, and while only a model call of a mod holds it:
78// the next prompt cancels that call, so it never needs to hold the queue back. A compaction does,
79// and so does another submission still on its way.
80export const canSend = (lock: Lock | undefined) => lock === undefined || lock === 'model-call'
81
82// ---- what `turn.complete` does ----
83
84export const onTurnStart = (q: Queue | undefined): Queue => ({ ...q, isWorking: true })
85
86export type Completed = { reason: 'answer' | 'aborted' | 'refusal' | 'error'; turnId: string }
87
88// A turn of the main conversation ended. Returns the new state and, when the queue sends now, the
89// prompt (already out of the state). The rules, in this order:
90//   1. empty or paused: nothing.
91//   2. the turn did not end with an answer (interrupted, error, refusal): PAUSE, send nothing.
92//   3. this turn already sent a prompt: nothing (the same event twice).
93//   4. the lock does not allow it (a compaction, another submission): nothing; the prompt waits.
94//   5. otherwise the first prompt is sent, and this turn is marked as having sent it.
95export const onTurnComplete = (q: Queue | undefined, e: Completed, lock: Lock | undefined): { state: Queue; send?: string } => {
96  const state: Queue = { ...q, isWorking: false }
97  const [first, ...rest] = state.items ?? []
98  if (first === undefined || state.pause) return { state }
99  if (e.reason !== 'answer') return { state: { ...state, pause: e.reason } }
100  if (state.sentFor === e.turnId || !canSend(lock)) return { state }
101  return { state: { ...state, items: rest, sentFor: e.turnId }, send: first }
102}
103
104// Claude Code refused or dropped the prompt just sent: it goes back to the top, the queue pauses.
105export const onSendFailed = (q: Queue | undefined, text: string): Queue => ({ ...q, items: [text, ...(q?.items ?? [])], pause: 'send-failed' })
106
107// ---- the `/q` command ----
108
109export type QResult = { text: string; state: Queue | undefined; send?: string }
110
111const quote = (text: string) => `"${oneLine(text, 60)}"`
112
113// What the person is told about the next send, after a change that may start one.
114const tail = (q: Queue, lock: Lock | undefined): { text: string; state: Queue; send?: string } => {
115  const [first, ...rest] = q.items ?? []
116  if (first === undefined) return { text: ' The queue is empty.', state: q }
117  if (q.pause) return { text: ` The queue is paused (${pauseText(q.pause)}): type /q resume to send.`, state: q }
118  if (q.isWorking) return { text: ' It sends when the running turn ends.', state: q }
119  if (lock === 'compaction') return { text: ' A compaction is running, so nothing is sent yet. Type /q resume when it ends.', state: q }
120  if (lock === 'prompt-submit') return { text: ' A queued prompt is on its way. This one sends when that turn ends.', state: q }
121  return { text: ` Claude is not working, so it sends now: ${quote(first)}`, state: { ...q, items: rest }, send: first }
122}
123
124export const runQ = (args: string, q: Queue | undefined, lock: Lock | undefined): QResult => {
125  const items = q?.items ?? []
126  const cmd = parseQ(args)
127
128  if (cmd.kind === 'list') {
129    if (items.length === 0) return { text: 'The prompt queue is empty. Type /q <text> to add a prompt.', state: q }
130    const status = q?.pause ? `paused: ${pauseText(q.pause)}` : 'sends one at a time when a turn ends'
131    const lines = items.map((item, i) => `  ${i + 1}. ${oneLine(item, 100)}`)
132    return { text: [`Prompt queue (${items.length} of ${MAX_ITEMS}, ${status}):`, ...lines].join('\n'), state: q }
133  }
134
135  if (cmd.kind === 'add') {
136    if (cmd.text === '') return { text: 'Nothing to queue. Type /q <text>.', state: q }
137    if ([...cmd.text].length > MAX_CHARS) return { text: `That prompt is ${[...cmd.text].length} characters. The limit is ${MAX_CHARS}. Nothing was queued.`, state: q }
138    if (items.length >= MAX_ITEMS) return { text: `The queue is full (${MAX_ITEMS} prompts). Remove one with /q rm <n> first.`, state: q }
139    const next: Queue = { ...q, items: [...items, cmd.text] }
140    const after = tail(next, lock)
141    return { text: `Queued ${items.length + 1} of ${MAX_ITEMS}: ${quote(cmd.text)}.${after.text}`, state: after.state, ...(after.send !== undefined ? { send: after.send } : {}) }
142  }
143
144  if (cmd.kind === 'rm') {
145    if (items.length === 0) return { text: 'The queue is empty. Nothing to remove.', state: q }
146    if (cmd.n < 1 || cmd.n > items.length) return { text: `There is no prompt ${cmd.n}. The queue has ${items.length}.`, state: q }
147    const removed = items[cmd.n - 1] as string
148    return { text: `Removed ${cmd.n}: ${quote(removed)}.`, state: { ...q, items: items.filter((_, i) => i !== cmd.n - 1) } }
149  }
150
151  if (cmd.kind === 'clear') {
152    if (items.length === 0 && !q?.pause) return { text: 'The queue is already empty.', state: q }
153    const { pause: _pause, ...kept } = q ?? {}
154    return { text: `Cleared ${items.length} queued prompt${items.length === 1 ? '' : 's'}.`, state: { ...kept, items: [] } }
155  }
156
157  if (cmd.kind === 'pause') {
158    return { text: q?.pause ? `The queue was already paused (${pauseText(q.pause)}).` : 'The queue is paused. Type /q resume to send again.', state: q?.pause ? q : { ...q, pause: 'user' } }
159  }
160
161  // resume: also the way to start a stalled queue (a prompt waited for a compaction to end).
162  const { pause: was, ...rest } = q ?? {}
163  const after = tail(rest, lock)
164  return { text: `${was ? 'Resumed.' : 'The queue was not paused.'}${after.text}`, state: after.state, ...(after.send !== undefined ? { send: after.send } : {}) }
165}
166
167// ---- the band row ----
168
169export const ITEM_CHARS = 24
170
171// The row's text, or undefined when the queue is empty. At most `columns` characters.
172//   queue (3): 1 fix tests · 2 update docs · …
173//   queue paused (3): you interrupted the turn · /q resume sends · /q clear drops
174export const queueRow = (q: Queue | undefined, columns: number): { text: string; isPaused: boolean } | undefined => {
175  const items = q?.items ?? []
176  if (items.length === 0) return undefined
177  const width = Number.isFinite(columns) && columns > 0 ? Math.floor(columns) : 80
178
179  if (q?.pause) return { text: clip(`queue paused (${items.length}): ${pauseText(q.pause)} · /q resume sends · /q clear drops`, width), isPaused: true }
180
181  const parts = items.map((item, i) => `${i + 1} ${oneLine(item, ITEM_CHARS)}`)
182  let text = `queue (${items.length}): ${parts[0]}`
183  let shown = 1
184  // Another item goes in only when it fits together with the `· …` that says more follow.
185  while (shown < parts.length && `${text} · ${parts[shown]}${shown + 1 < parts.length ? ' · …' : ''}`.length <= width) {
186    text += ` · ${parts[shown]}`
187    shown++
188  }
189  if (shown < parts.length) text += ' · …'
190  return { text: clip(text, width), isPaused: false }
191}
192
hooks/ship-gate.ts 351 lines
1// Ship Gate: two checks on shell commands that leave the machine or the working tree.
2//
3//   1. Publish gate: before `git push`, `gh repo create`, `gh api .../pages` or `gh repo edit
4//      --visibility`, show what would be published (the remote, the visibility, the author
5//      e-mails of the unpushed commits, personal strings in the tree) and ask Proceed or Cancel.
6//   2. Tested? gate: before `git commit` of code files, deny the commit when no test, type check
7//      or validator has passed on the current tree.
8//
9// Pure: no `$` here. The engine calls (`git`, `gh`, the dialog, the state) are in register.tsx.
10//
11// ponytail: a safety net, not a policy engine. It reads the command text with the same splitter
12// as Blast Radius, so it shares that ceiling: an alias, a script that calls git, or a command built
13// from variables is not seen. A `cd` to a path with a variable or `~` is not followed: the commit
14// check then lets the command through, and the publish check says it could not look.
15// Test detection is a regex over known runners (npm test, tsc, pytest, cargo test, ...). A custom
16// script such as ./check.sh is not known: commit with the marker `# ship-gate: skip-tests`.
17// The tree fingerprint is the paths of `git status` plus `git diff HEAD`, cut at OUTPUT_CAP characters: an edit
18// that changes only an untracked file's content, or only text beyond the cap, is not seen. Staging a tracked
19// file is not an edit, but `git add` of a new file after the check adds it to `git diff HEAD` and holds the commit.
20
21import { resolvePath, SAFE_NAME, SEPARATORS, stripPrefix, words } from './blast-radius'
22import { defineFeature } from './feature'
23import type { ModPackShipGate } from '../types'
24
25export const shipGate = defineFeature<ModPackShipGate>({
26  id: 'ship-gate',
27  title: 'Ship Gate',
28  about: 'asks Proceed or Cancel, with a check for personal info, before git push, gh repo create and Pages; denies git commit of code when no test passed since the last edit; costs no model tokens',
29  usesModel: false,
30  defaultOn: true,
31  last: state => {
32    const test = state?.test
33    return [state?.gate, test ? `last passing check: ${test.command}` : undefined].filter(Boolean).join('; ') || undefined
34  },
35})
36
37// ---- Reading a command ------------------------------------------------------------
38
39// Where a git or gh command runs. `path` undefined: the session folder. `isKnown` false: an earlier
40// `cd` or a `git -C` named a path that cannot be resolved (a variable, `~`, a glob).
41export type Dir = { path: string | undefined; isKnown: boolean }
42
43export type Commit = { dir: Dir; isCovered: boolean; isSkipped: boolean }
44export type PublishKind = 'push' | 'create' | 'pages' | 'visibility'
45export type Publish = { kind: PublishKind; segment: string; dir: Dir; remote?: string; flag?: string; hasSource: boolean }
46export type TestRun = { dir: Dir; name: string }
47export type Plan = { commits: Commit[]; publishes: Publish[]; tests: TestRun[] }
48
49export const SKIP_MARKER = 'ship-gate: skip-tests'
50
51// A segment that runs a test, a type check or a validator. A commit is `isCovered` when one stands before
52// it in the same command and only `&&` stands between them.
53const TEST = new RegExp(
54  String.raw`^(?:\.[\/])?(?:` +
55    String.raw`(?:(?:npm|pnpm|yarn|bun)(?:\s+run)?\s+(?:test|t|typecheck|check)(?::\S+)?)|` +
56    String.raw`(?:(?:npx|bunx|pnpx)(?:\s+-\S+)*\s+|(?:pnpm|yarn)\s+(?:exec|dlx)\s+|(?:\S*node_modules[\\/]\.bin[\\/]))?(?:tsc|vitest|jest|mocha|ava|playwright\s+test)|` +
57    String.raw`(?:node\s+(?:\S+\s+)*--test)|` +
58    String.raw`(?:(?:py\.?test|pytest|tox|nox|rspec|phpunit|ctest))|` +
59    String.raw`(?:python3?(?:\.exe)?\s+-m\s+(?:pytest|unittest))|` +
60    String.raw`(?:bundle\s+exec\s+rspec)|` +
61    String.raw`(?:cargo\s+(?:test|check|clippy|nextest))|` +
62    String.raw`(?:go\s+(?:test|vet))|` +
63    String.raw`(?:dotnet\s+test)|` +
64    String.raw`(?:mvn(?:w)?\s+(?:\S+\s+)*(?:test|verify))|` +
65    String.raw`(?:gradlew?(?:\.bat)?\s+(?:\S+\s+)*(?:test|check))|` +
66    String.raw`(?:make\s+(?:test|check))|` +
67    String.raw`(?:deno\s+(?:test|check))|` +
68    String.raw`(?:claude\s+plugin\s+(?:test|validate))|` +
69    String.raw`(?:invoke-pester)` +
70    String.raw`)(?=\s|$)`,
71  'i',
72)
73
74// A path that `cd` or `git -C` can be followed to.
75const isPlain = (path: string | undefined): path is string => path !== undefined && path !== '' && path !== '-' && !/[*?[\]$%`{}]|^~/.test(path)
76
77const CHANGES_DIRECTORY = /^(?:cd|pushd|chdir|set-location|sl)(?:\s|$)/i
78
79const cdTarget = (dir: Dir, segment: string): Dir => {
80  const target = words(segment).slice(1).find(w => !/^(?:-[A-Za-z]+|\/d)$/i.test(w))
81  if (!dir.isKnown || !isPlain(target)) return { path: dir.path, isKnown: false }
82  return { path: dir.path === undefined ? target : resolvePath(dir.path, target), isKnown: true }
83}
84
85type GitCall = { sub: string; args: string[]; dir: Dir }
86
87// `git [-C path] [-c k=v] [--opt] <sub> <args...>`. Undefined when the segment is not git.
88const gitCall = (segment: string, dir: Dir): GitCall | undefined => {
89  const all = words(segment)
90  if (!/^git(?:\.exe)?$/i.test(all[0] ?? '')) return undefined
91  let here = dir
92  let i = 1
93  for (; i < all.length; i++) {
94    const word = all[i] ?? ''
95    if (word === '-C') {
96      const path = all[++i]
97      here = !here.isKnown || !isPlain(path) ? { path: here.path, isKnown: false } : { path: here.path === undefined ? path : resolvePath(here.path, path), isKnown: true }
98    } else if (word === '-c') i++
99    else if (/^--(?:git-dir|work-tree)/.test(word)) here = { path: here.path, isKnown: false }
100    else if (!word.startsWith('-')) break
101  }
102  return { sub: all[i] ?? '', args: all.slice(i + 1), dir: here }
103}
104
105const hasFlag = (args: readonly string[], ...flags: string[]) => args.some(a => flags.includes(a) || flags.some(f => f.startsWith('--') && a.startsWith(`${f}=`)))
106
107const publishOf = (segment: string, dir: Dir): Publish | undefined => {
108  const git = gitCall(segment, dir)
109  if (git) {
110    if (git.sub !== 'push' || hasFlag(git.args, '--dry-run', '-n')) return undefined
111    const remote = git.args.find(a => !a.startsWith('-'))
112    return { kind: 'push', segment, dir: git.dir, ...(remote ? { remote } : {}), hasSource: false }
113  }
114  const all = words(segment)
115  if (!/^gh(?:\.exe)?$/i.test(all[0] ?? '')) return undefined
116  const args = all.slice(1)
117  if (args[0] === 'repo' && args[1] === 'create') {
118    const flag = args.find(a => a === '--public' || a === '--private' || a === '--internal')
119    return { kind: 'create', segment, dir, ...(flag ? { flag } : {}), hasSource: args.some(a => a === '--push' || a.startsWith('--source')) }
120  }
121  if (args[0] === 'repo' && args[1] === 'edit') {
122    const at = args.findIndex(a => a === '--visibility' || a.startsWith('--visibility='))
123    const value = at < 0 ? undefined : (args[at] ?? '').includes('=') ? (args[at] ?? '').split('=')[1] : args[at + 1]
124    return value === 'public' ? { kind: 'visibility', segment, dir, flag: '--visibility public', hasSource: false } : undefined
125  }
126  if (args[0] === 'api' && args.some(a => /(?:^|\/)pages(?:$|[/?])/.test(a))) {
127    const at = args.findIndex(a => a === '-X' || a === '--method' || /^--method=|^-X./.test(a))
128    const method = at < 0 ? undefined : (args[at] ?? '').replace(/^(?:-X|--method=?)/, '') || args[at + 1]
129    const changes = method ? method.toUpperCase() !== 'GET' : args.some(a => ['-f', '-F', '--field', '--raw-field', '--input'].includes(a))
130    return changes ? { kind: 'pages', segment, dir, hasSource: false } : undefined
131  }
132  return undefined
133}
134
135// Every commit, publish and test-like segment of `command`, in order. `cwd` is the session folder.
136export const plan = (command: string, cwd: string | undefined): Plan => {
137  const out: Plan = { commits: [], publishes: [], tests: [] }
138  const parts = command.split(new RegExp(`(${SEPARATORS.source})`))
139  const isSkipped = command.includes(SKIP_MARKER)
140  let dir: Dir = { path: cwd, isKnown: true }
141  let isTested = false
142  for (let i = 0; i < parts.length; i += 2) {
143    const segment = stripPrefix(parts[i] ?? '')
144    if (segment) {
145      if (CHANGES_DIRECTORY.test(segment)) dir = cdTarget(dir, segment)
146      else if (TEST.test(segment)) {
147        out.tests.push({ dir, name: segment.slice(0, 80) })
148        isTested = true
149      } else {
150        const git = gitCall(segment, dir)
151        if (git?.sub === 'commit' && !hasFlag(git.args, '--dry-run')) out.commits.push({ dir: git.dir, isCovered: isTested, isSkipped })
152        const publish = publishOf(segment, dir)
153        if (publish) out.publishes.push(publish)
154      }
155    }
156    // Only `&&` keeps a passed test in force for the next segment.
157    if (parts[i + 1] !== '&&') isTested = false
158  }
159  return out
160}
161
162// ---- The tree fingerprint and the code files --------------------------------------
163
164// FNV-1a over the text, with its length: a stand-in for "the same tree" that needs no library.
165export const fingerprintOf = (...texts: string[]): string => {
166  const text = texts.join('\u0000')
167  let hash = 0x811c9dc5
168  for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193) >>> 0
169  return `${hash.toString(16)}:${text.length}`
170}
171
172const CODE = /\.(?:[cm]?[jt]sx?|py|rs|go|java|kt|cs|c|cc|cpp|h|hpp|rb|php|swift|sh|ps1|lua|dart|scala|vue|svelte)$/i
173
174// The code files in `git status --porcelain` output (`XY path`, `XY old -> new`, quoted when odd).
175export const codeFiles = (status: string): string[] =>
176  status
177    .split(/\r?\n/)
178    .filter(line => line.length > 3 && !line.startsWith('!!'))
179    .map(line => line.slice(3).replace(/^.* -> /, '').replace(/^"|"$/g, ''))
180    .filter(path => CODE.test(path))
181
182// ---- Tested? gate text ------------------------------------------------------------
183
184const MAX_FILES = 5
185const minutes = (ms: number) => Math.max(0, Math.round(ms / 60_000))
186
187export const commitDeny = (files: readonly string[], test: ModPackShipGate['test'], now: number): string => {
188  const shown = files.slice(0, MAX_FILES).join(', ') + (files.length > MAX_FILES ? `, and ${files.length - MAX_FILES} more` : '')
189  const last = test
190    ? `The last passing check was "${test.command}"${Number.isFinite(now) ? `, ${minutes(now - test.at)} min ago` : ''}, on a different tree: files changed since.`
191    : 'No test, type check or validator has passed in this session.'
192  return [
193    `Ship Gate: git commit denied. It includes ${files.length} code ${files.length === 1 ? 'file' : 'files'} (${shown}), and no check passed on the current tree.`,
194    last,
195    `Run the project's tests (or tsc, or the validator) now, then commit again. If the person says to commit without a test, add the comment "# ${SKIP_MARKER}" to the commit command.`,
196  ].join(' ')
197}
198
199// ---- Publish gate: terms, facts, dialog -------------------------------------------
200
201const GENERIC_NAMES = new Set(['user', 'users', 'public', 'default', 'admin', 'administrator', 'runner', 'root', 'ubuntu', 'vagrant'])
202const NOREPLY = /noreply/i
203
204// The strings that must not be published: the user name in the folder path (`C:\Users\<name>`,
205// `/home/<name>`), the git e-mail unless it is a noreply address, and the person's own list
206// (the `shipGateTerms` setting, separated by commas or lines). The git user.name is not used: it is
207// public in every commit, and in many repos it is the account name that every link contains.
208export const personalTerms = (input: { path: string | undefined; email: string | undefined; extra: unknown }): string[] => {
209  const found: string[] = []
210  const home = /(?:^|[\\/])(?:Users|home)[\\/]([^\\/]+)/i.exec(input.path ?? '')?.[1]
211  if (home && home.length >= 3 && !GENERIC_NAMES.has(home.toLowerCase())) found.push(home)
212  const email = input.email?.trim()
213  if (email && email.includes('@') && !NOREPLY.test(email)) found.push(email)
214  if (typeof input.extra === 'string') found.push(...input.extra.split(/[,\n]/).map(t => t.trim()).filter(t => t.length >= 2))
215  const seen = new Set<string>()
216  return found
217    .filter(t => {
218      const key = t.toLowerCase()
219      if (seen.has(key)) return false
220      seen.add(key)
221      return true
222    })
223    .slice(0, 10)
224}
225
226export const MAX_HITS = 8
227
228// `git grep -n` output on a tree: `HEAD:path:line:text`. Cut to `path:line: text`.
229export const parseHits = (text: string | undefined): { total: number; shown: string[] } => {
230  const hits = (text ?? '').split(/\r?\n/).filter(Boolean)
231  const shown = hits.slice(0, MAX_HITS).map(line => {
232    const m = /^(?:HEAD:)?(.*?):(\d+):(.*)$/.exec(line)
233    return m ? `${m[1]}:${m[2]}: ${(m[3] ?? '').trim().slice(0, 80)}` : line.slice(0, 120)
234  })
235  return { total: hits.length, shown }
236}
237
238export type LogFacts = { count: number; offAddresses: string[]; termCommits: number }
239
240// `git log --format=%h<TAB>%ae<TAB>%ce<TAB>%s`: how many commits, which author or committer
241// addresses are not noreply addresses, and how many commits carry a personal string in an address or subject.
242export const parseLog = (text: string | undefined, terms: readonly string[]): LogFacts => {
243  const rows = (text ?? '').split(/\r?\n/).filter(Boolean)
244  const off = new Set<string>()
245  let termCommits = 0
246  for (const row of rows) {
247    const [, ae = '', ce = ''] = row.split('\t')
248    for (const address of [ae, ce]) if (address && !NOREPLY.test(address)) off.add(address)
249    const lower = row.toLowerCase()
250    if (terms.some(t => lower.includes(t.toLowerCase()))) termCommits++
251  }
252  return { count: rows.length, offAddresses: [...off].slice(0, 5), termCommits }
253}
254
255// `https://user:token@host/x` -> `https://***@host/x`: a credential never reaches the dialog.
256export const redactUrl = (url: string) => url.replace(/\/\/[^/@\s]+@/, '//***@')
257
258export const GITHUB_SLUG = /github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/
259
260export const NO_LOOK = 'not looked at'
261
262export type PublishFacts = {
263  kind: PublishKind
264  remote?: string
265  url?: string
266  // Text from `gh repo view --json visibility`: PUBLIC, PRIVATE or INTERNAL. Undefined when it could not be read.
267  visibility?: string
268  flag?: string
269  hasSource: boolean
270  isFolderKnown: boolean
271  terms: readonly string[]
272  // Raw `git log` text of the unpushed commits, when it could be read.
273  log?: string
274  // Raw `git grep` text. `grepRan`: the search ran (an empty text then means no hit).
275  hits?: string
276  grepRan: boolean
277}
278
279const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`
280
281export const describePublish = (f: PublishFacts): string[] => {
282  const out: string[] = []
283  if (!f.isFolderKnown) return ['the folder of the command could not be resolved (a variable or ~ in a cd or git -C), so nothing was looked at.']
284
285  if (f.kind === 'push') {
286    out.push(`remote ${f.remote ?? 'unknown'}: ${f.url ? redactUrl(f.url) : 'url not readable'}${f.visibility ? ` (${f.visibility.toLowerCase()})` : ' (visibility not readable)'}`)
287  } else if (f.kind === 'create') {
288    out.push(`creates a repository: ${f.flag ?? 'no visibility flag (gh asks, or defaults to private)'}${f.hasSource ? '; pushes the local folder' : ''}`)
289  } else if (f.kind === 'visibility') {
290    out.push(`makes the repository public${f.visibility ? ` (now ${f.visibility.toLowerCase()})` : ''}`)
291  } else {
292    out.push(`changes GitHub Pages: serves the repository files as a web page${f.visibility ? ` (repository is ${f.visibility.toLowerCase()})` : ''}`)
293  }
294
295  if (f.kind === 'push') {
296    if (f.log === undefined) out.push('unpushed commits: could not be listed.')
297    else {
298      const facts = parseLog(f.log, f.terms)
299      out.push(`${plural(facts.count, 'unpushed commit')}.`)
300      if (facts.offAddresses.length) out.push(`author or committer addresses that are not noreply addresses: ${facts.offAddresses.join(', ')}`)
301      else if (facts.count > 0) out.push('every author and committer address is a noreply address.')
302      if (facts.termCommits > 0) out.push(`${plural(facts.termCommits, 'commit')} with a personal string in an address or subject.`)
303    }
304  }
305
306  if (f.terms.length === 0) {
307    if (f.kind === 'push' || f.hasSource) out.push('personal strings: none to search for (no user name in the path, no non-noreply git e-mail, no shipGateTerms).')
308  } else if (f.grepRan) {
309    const { total, shown } = parseHits(f.hits)
310    out.push(
311      total === 0
312        ? `personal strings (${f.terms.join(', ')}): no hit in the text files at HEAD.`
313        : `personal strings (${f.terms.join(', ')}): ${plural(total, 'line')} in the text files at HEAD${total > shown.length ? `, first ${shown.length}` : ''}:`,
314    )
315    out.push(...shown.map(l => `  ${l}`))
316    out.push('binary files (images) are not searched.')
317  } else if (f.kind === 'push' || f.hasSource) out.push(`personal strings (${f.terms.join(', ')}): ${NO_LOOK} (git grep failed, or nothing is committed yet).`)
318  return out
319}
320
321export const SHIP_HEADER = 'Ship Gate' // 9 characters: the chip takes 12
322
323const shorten = (text: string, max: number) => (text.length > max ? `${text.slice(0, max)}...` : text)
324
325export const publishTitle = (kind: PublishKind) =>
326  ({ push: 'git push (publishes commits)', create: 'gh repo create (creates a repository)', pages: 'gh api pages (changes GitHub Pages)', visibility: 'gh repo edit --visibility public' })[kind]
327
328export const buildPublishQuestion = (command: string, kinds: readonly PublishKind[], facts: readonly string[]): string =>
329  [
330    `Ship Gate holds this command: ${kinds.map(publishTitle).join('; ')}.`,
331    '',
332    `  ${shorten(command, 400)}`,
333    '',
334    'What it would publish (a best-effort, read-only check):',
335    ...facts.map(l => `  ${l}`),
336    '',
337    'Run it now?',
338  ].join('\n')
339
340// What Claude reads when the command is refused.
341export const publishDeny = (answer: string | undefined, kinds: readonly PublishKind[]): string => {
342  const what = kinds.join(', ')
343  const tail = 'It did not run. Do not retry the same command unprompted; ask the person what they want instead.'
344  return answer === undefined
345    ? `Ship Gate: no confirmation was given for this command (${what}): the dialog was dismissed, or nobody could be asked (a non-interactive run). ${tail}`
346    : `Ship Gate: the person cancelled this command (${what}). ${tail}`
347}
348
349// The gate itself crashed before a publish command ran: the command is refused, never let through unchecked.
350export const publishFailedDeny = 'Ship Gate: its own check failed before this publish command ran, so the command did not run. Tell the person, and run it again.'
351
hooks/settings.ts 68 lines
1// Pure helpers: which features are ON, which rows fit. No engine calls here,
2// so they can be tested without the engine.
3
4import type { PluginOptions } from 'claude-code'
5
6import type { Feature } from './feature'
7
8// Runtime choices made with /mods. They beat the user's settings until `/mods reset`.
9export type Overrides = { features: Record<string, boolean>; sound?: boolean }
10
11// Key in $.store.
12export const STORE_KEY = 'mod-pack/overrides'
13
14export const NO_OVERRIDES: Overrides = { features: {} }
15
16// `token-weather` -> `tokenWeather`: the userConfig key of a feature.
17export const optionKey = (id: string) => id.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase())
18
19// Read what $.store returned. Anything that is not the expected shape counts as no override.
20export const parseOverrides = (raw: unknown): Overrides => {
21  if (typeof raw !== 'object' || raw === null) return { features: {} }
22  const { features, sound } = raw as { features?: unknown; sound?: unknown }
23  const clean: Record<string, boolean> = {}
24  if (typeof features === 'object' && features !== null) {
25    for (const [id, value] of Object.entries(features)) if (typeof value === 'boolean') clean[id] = value
26  }
27  return typeof sound === 'boolean' ? { features: clean, sound } : { features: clean }
28}
29
30// Override, else the user's setting, else the feature's own default.
31export const isFeatureOn = (feature: Pick<Feature, 'id' | 'defaultOn'>, overrides: Overrides, options: PluginOptions) => {
32  const set = options[optionKey(feature.id)]
33  return overrides.features[feature.id] ?? (typeof set === 'boolean' ? set : feature.defaultOn)
34}
35
36// The global sound switch: override, else the `sound` setting, else OFF.
37export const isSoundOn = (overrides: Overrides, options: PluginOptions) => overrides.sound ?? options.sound === true
38
39// Sound plays only if the global switch is ON, the feature is ON and the feature can play sound.
40export const isSoundAllowed = (feature: Pick<Feature, 'hasSound'>, isOn: boolean, isGlobalSoundOn: boolean) =>
41  isGlobalSoundOn && isOn && feature.hasSound === true
42
43// How many rows the pack may add to the band. `maxRows` is what the whole band
44// may take, for all plugins together. The pack cannot measure what the plugins
45// beneath it drew, so it claims at most one third of `maxRows`, and never more
46// than MAX_OWN_ROWS. Under 3 rows of room it adds none.
47export const MAX_OWN_ROWS = 4
48// The most lines one mod's row may take (a feature's `bandLines`). Wait What takes 2.
49export const MAX_ROW_LINES = 2
50export const rowBudget = (maxRows: number) => Math.max(0, Math.min(MAX_OWN_ROWS, Math.floor(maxRows / 3)))
51
52// Gives each row that draws its lines, first row first, out of `budget` lines. `asked` is what the
53// feature's `bandLines` said (1 when it has none). A row gets 1 line up to MAX_ROW_LINES, never more
54// than the lines left: it is then clipped to its first lines. Once no line is left, the rows after it
55// are dropped. The lines a row took are counted against every row after it.
56export const layoutRows = <T extends { asked: number }>(candidates: readonly T[], budget: number): (T & { lines: number })[] => {
57  const out: (T & { lines: number })[] = []
58  let used = 0
59  for (const candidate of candidates) {
60    if (used >= budget) break
61    const wanted = Number.isFinite(candidate.asked) ? Math.floor(candidate.asked) : 1
62    const lines = Math.max(1, Math.min(MAX_ROW_LINES, wanted, budget - used))
63    out.push({ ...candidate, lines })
64    used += lines
65  }
66  return out
67}
68
hooks/snake.tsx 93 lines
1// Snake: a small Snake game in a pane. It pauses when Claude finishes a turn and goes on when the
2// next turn starts, so it fills the time that you wait. `/snake` opens the pane, and `/snake` again
3// (or Esc, while the pane has the keyboard) closes it. It costs no model tokens and makes no call.
4//
5// Pure: no `$` here. This file holds the Feature entry and the drawing of the pane. The rules are in
6// hooks/snake-game.ts. The pane, the timer, the pause on `turn.complete` and the commands are in
7// register.tsx. Snake draws a Pane, not a row of the band: it has no `band` function, so the
8// compositor never sees it and it takes no row of the budget.
9//
10// Keys: w, a, s, d turn the snake, p plays and pauses, r starts a new game. They are the `hotkey` of
11// Buttons in the pane, and a Button's hotkey works only while the pane holds the keyboard (the
12// declared API: ctrl+x then Tab, or a click on the pane, or `focus` when it opens). The arrow keys
13// and Tab belong to the engine. A `Client` module could read the arrows once a click has focused it,
14// but it runs in a separate drawing thread with no `$`, so the game and its pause on `turn.complete`
15// would be split across two places. The Buttons keep the whole game in one place. No key is a digit:
16// a digit hotkey also fires when the person types that digit into an empty prompt, and next-steps owns 0 to 3.
17
18import type { RenderElement } from 'claude-code'
19
20import { defineFeature } from './feature'
21import type { Terminal } from './feature'
22import { boardRows, CELL_WIDTH, COLS, ROWS, statusText } from './snake-game'
23import type { CellKind, Dir, SnakeGame } from './snake-game'
24
25// The Feature entry. It has no callbacks: the dispatcher reads its id, text and default, for `/mods`
26// and for the on/off check. It makes no model call, so it costs no tokens.
27export const snake = defineFeature<never>({
28  id: 'snake',
29  title: 'Snake',
30  about: 'type /snake to play Snake in a pane; it pauses when Claude finishes and goes on when you send the next prompt; keys w a s d p r work while the pane has the keyboard; costs no model tokens',
31  usesModel: false,
32  defaultOn: true,
33})
34
35// The narrowest pane that shows the whole board.
36export const MIN_COLUMNS = COLS * CELL_WIDTH
37
38// Rows of the body: the title line, the status line, the board, 2 lines of keys, 1 line of hint.
39export const PANE_ROWS = ROWS + 5
40
41export type SnakeActions = {
42  steer: (dir: Dir) => void
43  toggle: () => void
44  restart: () => void
45}
46
47type Look = { color?: string; dimColor?: boolean; bold?: boolean }
48const LOOK: Record<CellKind, Look> = {
49  empty: { dimColor: true },
50  body: { color: 'green' },
51  head: { color: 'yellow', bold: true },
52  food: { color: 'red' },
53}
54
55// What the pane shows for a game. `props` are the Pane's: how wide the body is, and whether the pane
56// holds the keyboard now. Each function is pure: a Button's handler is one of `actions`, which the
57// dispatcher made.
58export function snakeView(game: SnakeGame, props: { bodyColumns: number; isFocused: boolean }, el: Terminal, actions: SnakeActions): RenderElement {
59  const { Box, Button, Text } = el
60
61  if (props.bodyColumns < MIN_COLUMNS) {
62    return <Text color="yellow">{`Snake needs a pane ${MIN_COLUMNS} columns wide. This one is ${props.bodyColumns}. Widen the terminal.`}</Text>
63  }
64
65  const state = statusText(game)
66  const stateColor = game.status === 'running' ? undefined : game.status === 'over' ? 'red' : 'yellow'
67
68  return (
69    <Box flexDirection="column">
70      <Text bold>{`Snake  score ${game.score}  best ${game.best}`}</Text>
71      {stateColor ? <Text color={stateColor} bold>{state}</Text> : <Text dimColor>{state}</Text>}
72      {boardRows(game).map((runs, y) => (
73        <Box key={`row-${y}`} height={1}>
74          {runs.map((run, i) => (
75            <Text key={`run-${i}`} {...LOOK[run.kind]}>{run.text}</Text>
76          ))}
77        </Box>
78      ))}
79      <Box gap={1}>
80        <Button key="up" label="up" hotkey="w" plain onPress={() => actions.steer('up')} />
81        <Button key="left" label="left" hotkey="a" plain onPress={() => actions.steer('left')} />
82        <Button key="down" label="down" hotkey="s" plain onPress={() => actions.steer('down')} />
83        <Button key="right" label="right" hotkey="d" plain onPress={() => actions.steer('right')} />
84      </Box>
85      <Box gap={1}>
86        <Button key="toggle" label={game.status === 'running' ? 'pause' : 'play'} hotkey="p" plain onPress={actions.toggle} />
87        <Button key="restart" label="restart" hotkey="r" plain onPress={actions.restart} />
88      </Box>
89      {props.isFocused ? <Text dimColor>Esc closes the pane.</Text> : <Text dimColor>Keys need the pane focused: click it, or ctrl+x then Tab.</Text>}
90    </Box>
91  )
92}
93
hooks/snake-game.ts 201 lines
1// Snake, the pure part: the board, the rules of a move, the pauses, and the text of the board.
2// No engine calls here and no `$`. The dispatcher (register.tsx) keeps the game in `$.state`, runs
3// `step` on a timer, and calls the pause and resume functions when a turn ends or starts.
4//
5// Every function takes a game and returns a new one. The random numbers come from a function that
6// the caller passes in (`tick`, `newGame`), so a test can make the food land where it wants. The
7// game itself keeps only a number, `seed`, which is the state of a small generator (`makeRng`). The
8// seed is JSON, so a game that was stored and read back goes on with the same food.
9
10import type { ModPackSnake, ModPackSnakeCell } from '../types'
11
12export type SnakeGame = ModPackSnake
13export type Cell = ModPackSnakeCell
14export type Dir = SnakeGame['dir']
15// Who paused the game: the person (`p`, closing the pane, a new game that was not started yet),
16// Claude finishing a turn, or a Blast Radius question that is open.
17export type PauseWhy = NonNullable<SnakeGame['pause']>
18
19// The board is 16 cells across and 8 down. One cell is 2 terminal columns wide, so it looks square:
20// the board is 32 columns by 8 rows. It is small on purpose: a pane has about a third of the screen.
21export const COLS = 16
22export const ROWS = 8
23export const CELL_WIDTH = 2
24export const START_LENGTH = 3
25// Turns that may wait for a tick. A third key press before the next tick is dropped: three presses in
26// 150 ms are a key held down or a mistake, and a long queue would steer the snake after the person let go.
27export const MAX_QUEUED_TURNS = 2
28
29const STEP: Record<Dir, Cell> = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } }
30const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
31
32// ---- Random numbers -----------------------------------------------------------------
33
34// mulberry32: 32 bits of state, one number in [0, 1) per call. `state()` is what to keep as the seed.
35export const makeRng = (seed: number) => {
36  let a = Number.isFinite(seed) ? seed >>> 0 : 1
37  return {
38    next: (): number => {
39      a = (a + 0x6d2b79f5) >>> 0
40      let t = a
41      t = Math.imul(t ^ (t >>> 15), t | 1)
42      t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
43      return ((t ^ (t >>> 14)) >>> 0) / 4294967296
44    },
45    state: () => a,
46  }
47}
48
49// ---- The board ----------------------------------------------------------------------
50
51const key = (c: Cell) => `${c.x},${c.y}`
52const same = (a: Cell, b: Cell) => a.x === b.x && a.y === b.y
53
54// A free cell chosen with `rng`, or null when the snake fills the board.
55export const placeFood = (cols: number, rows: number, body: readonly Cell[], rng: () => number): Cell | null => {
56  const taken = new Set(body.map(key))
57  const free: Cell[] = []
58  for (let y = 0; y < rows; y++) for (let x = 0; x < cols; x++) if (!taken.has(`${x},${y}`)) free.push({ x, y })
59  if (free.length === 0) return null
60  return free[Math.min(free.length - 1, Math.floor(rng() * free.length))] ?? null
61}
62
63// A new game. It is paused by the person: nothing moves until `p` (or `r`, the restart key). `best` is
64// the high score so far. The board must be at least START_LENGTH + 1 cells across.
65export const newGame = (seed: number, best = 0, cols = COLS, rows = ROWS): SnakeGame => {
66  const x = Math.max(START_LENGTH - 1, Math.floor(cols / 3))
67  const y = Math.floor(rows / 2)
68  const body: Cell[] = []
69  for (let i = 0; i < START_LENGTH; i++) body.push({ x: x - i, y })
70  const rng = makeRng(seed)
71  const food = placeFood(cols, rows, body, rng.next)
72  return { cols, rows, body, dir: 'right', queue: [], food, score: 0, best, status: 'paused', pause: 'person', seed: rng.state() }
73}
74
75// ---- Steering -----------------------------------------------------------------------
76
77// A turn to wait for the next tick. A 180 degree turn is dropped, and so is the direction the snake
78// already has (or will have, when a turn is waiting): the check is made against the LAST waiting
79// direction, so right, up, left in quick order is allowed, and right then left is not. A key that comes
80// while the game is not running is dropped.
81export const steer = (game: SnakeGame, dir: Dir): SnakeGame => {
82  if (game.status !== 'running') return game
83  const last = game.queue[game.queue.length - 1] ?? game.dir
84  if (dir === last || dir === OPPOSITE[last]) return game
85  if (game.queue.length >= MAX_QUEUED_TURNS) return game
86  return { ...game, queue: [...game.queue, dir] }
87}
88
89// ---- One move -----------------------------------------------------------------------
90
91// One tick. The snake takes the first waiting turn, or goes straight on, and moves one cell. A wall or
92// its own body ends the game (`status: 'over'`, the body stays where it was). Food is eaten when the
93// head lands on it: the score goes up by 1, the tail stays (the snake grows), and new food is placed
94// with `rng`. When no cell is free the person has won: `status: 'over'` with `isWon`. The tail cell
95// counts as free when the snake does not grow, because the tail moves away in the same tick.
96export const tick = (game: SnakeGame, rng: () => number): SnakeGame => {
97  if (game.status !== 'running') return game
98  const dir = game.queue[0] ?? game.dir
99  const queue = game.queue.slice(1)
100  const head = game.body[0]
101  if (!head) return { ...game, status: 'over' }
102  const step = STEP[dir]
103  const next = { x: head.x + step.x, y: head.y + step.y }
104  const over = { ...game, dir, queue, status: 'over' as const }
105
106  if (next.x < 0 || next.y < 0 || next.x >= game.cols || next.y >= game.rows) return over
107
108  const ate = game.food !== null && same(next, game.food)
109  const kept = ate ? game.body : game.body.slice(0, -1)
110  if (kept.some(c => same(c, next))) return over
111
112  const body = [next, ...kept]
113  const score = game.score + (ate ? 1 : 0)
114  const moved = { ...game, body, dir, queue, score, best: Math.max(game.best, score) }
115  if (!ate) return moved
116
117  const food = placeFood(game.cols, game.rows, body, rng)
118  return food === null ? { ...moved, food, status: 'over', isWon: true } : { ...moved, food }
119}
120
121// `tick` with the generator made from the game's own seed. The new game holds the generator's new state.
122export const step = (game: SnakeGame): SnakeGame => {
123  const rng = makeRng(game.seed)
124  return { ...tick(game, rng.next), seed: rng.state() }
125}
126
127// ---- Pause, resume, restart ---------------------------------------------------------
128
129const running = (game: SnakeGame): SnakeGame => {
130  const { pause: _pause, ...rest } = game
131  return { ...rest, status: 'running' }
132}
133
134// Pause a running game for `why`. A game that is paused already keeps its reason, with one exception:
135// Claude finishing a turn takes over from an open Blast Radius question, because the question can no
136// longer be the reason once the turn has ended. A game that is over stays over.
137export const pauseFor = (game: SnakeGame, why: PauseWhy): SnakeGame => {
138  if (game.status === 'running') return { ...game, status: 'paused', pause: why }
139  if (game.status === 'paused' && game.pause === 'question' && why === 'claude') return { ...game, pause: why }
140  return game
141}
142
143// Resume a game, but only one that `why` paused: a game that the person paused stays paused when Claude
144// starts a turn, and a game that Claude's end paused stays paused when a question closes.
145export const resumeFrom = (game: SnakeGame, why: PauseWhy): SnakeGame => (game.status === 'paused' && game.pause === why ? running(game) : game)
146
147// The pane closes, or the session ends: a game that runs or waits for Claude is paused by the person, so
148// that no turn starts it again while nothing draws it. A game that is over stays over.
149export const halt = (game: SnakeGame): SnakeGame => (game.status === 'over' ? game : { ...game, status: 'paused', pause: 'person' })
150
151// The person's play and pause key: running to paused by the person, paused (for any reason) to running.
152// A game that is over does nothing: `r` starts a new one.
153export const toggle = (game: SnakeGame): SnakeGame => {
154  if (game.status === 'running') return pauseFor(game, 'person')
155  if (game.status === 'paused') return running(game)
156  return game
157}
158
159// A new game on the same board size, with the high score kept and the game running at once (the person
160// pressed `r` on purpose). The seed goes on from where it was, so the food is not the same.
161export const restart = (game: SnakeGame): SnakeGame => running(newGame(game.seed, Math.max(game.best, game.score), game.cols, game.rows))
162
163// The high score as it was stored: a whole number from 0 up. Anything else counts as 0.
164export const parseBest = (raw: unknown): number => (typeof raw === 'number' && Number.isInteger(raw) && raw >= 0 ? raw : 0)
165
166// ---- Text ---------------------------------------------------------------------------
167
168// The line that says what the game is doing.
169export const statusText = (game: SnakeGame): string => {
170  if (game.status === 'running') return 'playing'
171  if (game.status === 'over') return game.isWon ? 'YOU WIN – the board is full. Press r for a new game.' : 'GAME OVER – press r for a new game.'
172  if (game.pause === 'claude') return 'PAUSED – Claude finished'
173  if (game.pause === 'question') return 'PAUSED – a question is open'
174  return 'PAUSED – press p to play'
175}
176
177export type CellKind = 'empty' | 'body' | 'head' | 'food'
178export type Run = { kind: CellKind; text: string }
179
180const GLYPH: Record<CellKind, string> = { empty: '· ', body: '██', head: '██', food: '● ' }
181
182// The board as rows of runs: neighbouring cells of one kind are one run, so a row is a few `Text`
183// elements and not 16. Every row is exactly cols * CELL_WIDTH characters.
184export const boardRows = (game: SnakeGame): Run[][] => {
185  const kinds = new Map<string, CellKind>()
186  game.body.forEach((c, i) => kinds.set(key(c), i === 0 ? 'head' : 'body'))
187  if (game.food) kinds.set(key(game.food), 'food')
188  const rows: Run[][] = []
189  for (let y = 0; y < game.rows; y++) {
190    const runs: Run[] = []
191    for (let x = 0; x < game.cols; x++) {
192      const kind = kinds.get(`${x},${y}`) ?? 'empty'
193      const last = runs[runs.length - 1]
194      if (last && last.kind === kind) last.text += GLYPH[kind]
195      else runs.push({ kind, text: GLYPH[kind] })
196    }
197    rows.push(runs)
198  }
199  return rows
200}
201
hooks/token-weather.tsx 52 lines
1// Token Weather: the context window as a forecast, in one row above the prompt.
2// Icon and word by percent band, the percent, tokens / window, a 12-turn chart,
3// and the change since the last turn. Moving up into Storm or Compact soon plays
4// thunder when sound is allowed.
5//
6// Pure: it holds no `$`. The dispatcher (register.tsx) passes in the context
7// reading and the earlier history, and keeps the history this returns.
8
9import { defineFeature } from './feature'
10import { chart, delta, fmt, percentOf, shouldThunder, weather } from './forecast'
11import type { Sample } from './forecast'
12
13export const tokenWeather = defineFeature<Sample[]>({
14  id: 'token-weather',
15  title: 'Token Weather',
16  about: 'context-window forecast above the prompt: band, percent, tokens, 12-turn chart',
17  hasSound: true,
18  defaultOn: true,
19
20  turnComplete(history = [], { e, context }, ctx) {
21    // A subagent's turn does not move the main window; it would only add a copy of the last sample.
22    if (e.agentId !== undefined || context?.tokens === undefined) return undefined
23
24    const sample = { tokens: context.tokens, window: context.window }
25    const last = history.at(-1)
26
27    return {
28      state: [...history, sample].slice(-12),
29      // `last` comes from the stored history, which survives a reload: a reload cannot replay the sound.
30      sound: shouldThunder(last && percentOf(last), percentOf(sample), ctx.isSoundAllowed) ? 'assets/thunder.wav' : undefined,
31    }
32  },
33
34  band(history = [], _e, el) {
35    const now = history.at(-1)
36    if (!now) return null
37
38    const percent = percentOf(now)
39    const [, icon, word, color] = weather(percent)!
40    const { Box, Text } = el
41
42    return (
43      <Box>
44        <Text color={color} bold>{icon} {word}</Text>
45        <Text> {percent}% {fmt(now.tokens)} / {fmt(now.window)} </Text>
46        <Text color={color}>{chart(history)}</Text>
47        <Text dimColor> {delta(history)}</Text>
48      </Box>
49    )
50  },
51})
52