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.

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.
| Mod | Status | What it does |
|---|---|---|
Token Weather (token-weather) | available | One 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) | available | Before 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) | available | Two 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) | available | One 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) | available | Type /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 default | After 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) | available | Type /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 window | Icon and word | Colour |
|---|---|---|
| under 25 | ☀ Clear | yellow |
| 25 to 49 | ☁ Cloudy | cyan |
| 50 to 74 | ☂ Showers | blue |
| 75 to 89 | ☇ Storm | magenta |
| 90 and over | ↯ Compact soon | red |
The window size is read from Claude Code (context.window). It is not fixed in the code.
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 left | Row | Colour |
|---|---|---|
| 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 |
$.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.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.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./clear, the row goes until the next response: the new, shorter history has no cache entry yet.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 runs | Lifetime |
|---|---|
| Claude subscription, within the plan's included usage | 1 hour |
| Claude subscription, after the plan usage ran out and usage credits are used | 5 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:
Switch it off: /mods off cache-keeper (at once), or set cacheKeeper to false.
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:
haiku (the cheapest: no dated model id is written in the code), made through your own Claude Code session on your own plan.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).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.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:
| Rule | Detail |
|---|---|
| The mod is on | /mods, then the setting, then the default (off). |
| The main conversation | A subagent's turn is never retold, and it does not clear the retell of the main answer. |
| A real answer | The turn ended with the reason answer. An interrupt, an error and a refusal are not retold. |
| Long enough | 200 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 it | See "The hourly cap". |
| No compaction runs, no other model call runs | The shared lock is free. See "Compatibility". |
| The clock can be read | A 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.
…. 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.turn.start), at /clear, and when a newer answer ends./mods off wait-what during a call lets that call end (15 seconds at most) and drops its reply.mod-pack: wait-what: <the same note>. It still counts toward the hourly cap, so a failing model cannot be called without limit.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 band | What it means | What 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 unreadable | The 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 running | A 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 sent | The 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 running | A 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 lock | The 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 text | The model answered, and nothing was left after the reply was cleaned of escape and control characters. | Ask again. |
wait-what: hourly limit reached | The 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.
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.wait-what: hourly limit reached, until the next prompt. The next answer is retold as soon as the oldest call has left the hour./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.Limits:
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.$.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.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 type | Effect |
|---|---|
/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 list | List 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 clear | Remove every prompt, and lift a pause. |
/q pause | Send nothing until /q resume. |
/q resume | Lift 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:
| Rule | Detail |
|---|---|
| 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 answer | A 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 paused | By /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 way | The shared lock. See "Compatibility". A prompt that cannot be sent for this reason stays in the queue. |
| It is the first send for this turn | One 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
hooks/register.tsx 983 lines1// 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}
983hooks/blast-radius.ts 363 lines1// 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}
363hooks/cache-keeper.tsx 95 lines1// 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})
95hooks/feature.ts 150 lines1// 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
150hooks/mods-command.ts 64 lines1// 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}
64hooks/prompt-queue.tsx 63 lines1// 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})
63hooks/queue.ts 192 lines1// 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}
192hooks/ship-gate.ts 351 lines1// 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.'
351hooks/settings.ts 68 lines1// 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}
68hooks/snake.tsx 93 lines1// 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}
93hooks/snake-game.ts 201 lines1// 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}
201hooks/token-weather.tsx 52 lines1// 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