A Claude Code mod for Eunuch Mode: while the skill is on, the palace adviser stands above the prompt in pixel art, posing for what the agent is doing; the…

A Claude Code mod for Eunuch Mode. While the skill is on:
cargo, and "HIGH TREASON" for git push --force. Claude Code keeps its own elapsed-time and token count beside it.👑 Court in session in the status line.| The agent is… | Pose | Stage direction |
|---|---|---|
| thinking | whisper, a hand raised, eyes sliding sideways | [whispers behind a sleeve] |
| running a tool (shell, read, search, web, tests, builds…) | fawning bow, bobbing | [bows low] |
| editing or writing a file | scribbling on a scroll, quill moving | [scribbles on the royal scroll] |
running git push --force, rm -rf, git reset --hard | alarm: brows up, mouth open, a bead of sweat | [gasps] |
| failing (a tool error, an interrupted turn) | side-eye, one brow raised | [narrows his eyes] |
| done | a smug bow, then a sly look up | [a small, satisfied bow] |
There are about 250 curated lines across 32 kinds of activity. When a kind runs dry, the court roster takes over ("Consulting the Keeper of the Flaky Tests"), giving several hundred more, so no line repeats until several hundred have been spoken (/court shows the count). None of them calls a model, so by default the mod costs nothing. Two opt-in modes add more: generative mode (a model writes fresh lines) and treachery mode (he plots your downfall, cosmetically).
Off by default. When it is on, a model writes fresh spinner lines on the fly, in the same voice, on top of the canned ones.
/court generative on turn it on (remembered across sessions)
/court generative off turn it off
/court generative model haiku pick the model (default: sonnet, Claude Code's alias for the current Sonnet)
/court generative status calls used, fallbacks, tokens, and the latest lines it wrote
Or set EUNUCH_MODE_GENERATIVE=1 (and optionally EUNUCH_MODE_MODEL) in the environment; /court generative off overrides it.
How it works. The canned line always appears first, instantly. While the adviser thinks between actions, the mod asks the model for one line about what just happened. If the line arrives while that moment is still on screen, it replaces the canned one; otherwise it is saved for the next moment of the same kind. Generated lines join the same no-repeat rotation.
Cost and limits. Calls go through Claude Code's own $.model.complete, so they are billed to your Claude Code session (your subscription's usage, or your API account): no separate key, and the mod never sees one. Measured on a real session: about 350 input and 20 output tokens per call. Guards:
/court generative status shows the count);Any timeout or error leaves the canned line in place. The agent never waits on any of it: the call starts after the tool's result has gone back.
Privacy: exactly what is sent. Each request carries a fixed system prompt (the voice rules) and one message built from four fields, each checked against a strict pattern:
edit, tests, treason…);Edit, Bash; an MCP tool is sent as "an external tool");.ts), never the path or the file name;pytest, git push), never its arguments, flags or paths.Nothing else leaves: no file contents, no paths, no command arguments, no prompts, no transcript, no secrets. For example, curl -H "Authorization: Bearer sk-…" https://internal/acme is sent as Activity: courier. Tool: Bash. Command: curl. The tests prove it (test-node/generative.test.mjs, "sanitisation"). Replies are filtered before they are shown: one line, 8 to 60 characters, letters and simple punctuation only, no links or paths, and a blocklist that keeps the voice rules (no jokes about the eunuch condition, no real people or cultures).
The plotting is purely cosmetic. He schemes; he cannot act.
Off by default. /court treachery on and the adviser stays fawning to your face while he keeps a hidden Ledger of Grievances:
| Your sin | In the ledger | Weight |
|---|---|---|
git push --force | Rewrote the chronicle by force | 4 |
rm -rf | Burned a wing of the archive | 2 |
--no-verify | Slipped past the gatekeepers unchecked | 2 |
skipping a test (it.skip, @pytest.mark.skip…) | Excused a witness from testifying | 2 |
| a failing test run | Let the food taster find poison | 1 |
| a diff of 400+ lines | Delivered a scroll too heavy to lift | 1 |
| deploying or pushing on a Friday | Sent a decree to the provinces on a Friday | 3 |
git revert | Unmade a decree the court had praised | 1 |
Each sin earns a whispered aside in the spinner ("Noted for the ledger", "The junior developer would never have done that"), and a plot meter (Plot ▰▰▰▱▱▱▱▱▱▱) fills under the adviser. His pose escalates with it: side-eye, then writing in a small black book, then whispering to a hooded figure, then scheming by candlelight. At 10/10 a coup is attempted at the end of the turn, and it always fails ("The coup has been postponed due to a merge conflict"); the meter resets and the ledger remembers. /court ledger reads it aloud.
What it reads: the tool calls Claude has already made, and whether they failed. What it changes: the court's own drawing, and nothing else. It never alters a tool call, a prompt, the model's context, git, files or permissions, and none of it is in the skill's instructions. The tests scan the mod's source for any call that could act (running commands, touching files, calling tools, submitting prompts, adding model context, refusing anything) and find none. With generative mode on, the model may write the asides too, from the kind of sin alone ("force-push"), never the command.
Needs Claude Code 2.1.287 or later. In Claude Code:
/plugin marketplace add conorbronsdon/eunuch-mode
/plugin install eunuch-mode-court@eunuch-mode
/reload-plugins
Or from a shell: claude plugin marketplace add conorbronsdon/eunuch-mode then claude plugin install eunuch-mode-court@eunuch-mode. To try it from a clone without installing: claude --plugin-dir plugins/eunuch-mode-court.
The mod is optional and separate from the skill. Install the skill the usual way (README); the mod only watches for it.
| You | The court | |
|---|---|---|
say "eunuch mode", "vizier mode" or /eunuch-mode, or the agent loads the skill | convenes | |
| say "drop the bit", "normal mode" or "exit eunuch mode" | adjourns | |
/court on / /court off | convenes or adjourns for this session | |
/court always | convenes in every session, skill or not | |
/court skill | back to the default: only with the skill | |
/court never | never convenes | |
| `/court generative on\ | off` | fresh model-written lines (below) |
| `/court treachery on\ | off` | he plots your downfall (below) |
/court ledger | the Ledger of Grievances |
It never blocks, denies or rewrites a tool call, a prompt or the model's output: every hook on the agent's work passes the event on unchanged. A tool call starts at once, with the court's bookkeeping (a few in-session state writes) running beside it. The one thing it rewrites is the spinner's display text, and it yields that whenever Claude Code sets its own spinner message (compacting, for example). On a terminal narrower than about 40 columns, the band shrinks to one line.
claude plugin validate plugins/eunuch-mode-court
claude plugin test plugins/eunuch-mode-court # runtime tests (tests/)
node --test plugins/eunuch-mode-court/test-node/*.test.mjs # pure mapping and rotation tests (Node 22.18+)
The APIs it relies on, with links, are in docs/mods-api.md. The lines are in hooks/lines.ts and the sprites in hooks/sprites.ts. Each sprite is a 14×12 grid of palette letters, so it is easy to edit by hand.
hooks/court.ts 806 lines1// Eunuch Mode court: while the eunuch-mode skill is on, the palace adviser
2// stands above the prompt in pixel art, the spinner narrates each action in
3// court language, and the status line reads "👑 Court in session".
4//
5// Hooks on the agent's work (prompts, skills, turns, tool calls) observe and
6// pass the event on with next(e), unchanged. A tool call starts at once: the
7// court's bookkeeping runs beside it, not before it. The drawings are the
8// mod's own: the spinner keeps Claude Code's line with the court's text in it,
9// the band above the prompt is drawn only while the court is in session, and
10// /court is the mod's own command. By default it makes no model calls: every
11// line is canned (lines.ts). Generative mode, off unless the person turns it
12// on, asks a model for fresh lines in the background (generative.ts); the
13// canned line is always shown first, so a slow or failed call changes nothing.
14// Treachery mode, also opt-in, keeps a hidden Ledger of Grievances and a plot
15// meter (treachery.ts). It is drawing only: he schemes; he cannot act.
16
17import type { EngineInterface, On, Timer } from 'claude-code'
18import type { CourtLedger, CourtScene } from '../types'
19import { POSE_OF, classifyTool, detailOf, isCourtSkill, pickLine, promptIntent, stageFor, type Activity } from './lines.ts'
20import { frameOf, toRows } from './sprites.ts'
21import {
22 DEFAULT_MODEL,
23 EMPTY_GEN,
24 MAX_CALLS_PER_SESSION,
25 MAX_TOKENS,
26 SYSTEM,
27 TIMEOUT_MS,
28 filterLine,
29 mayCall,
30 promptFor,
31 summarize,
32 takeBanked,
33 asidePrompt,
34 type ActivitySummary,
35 type GenState,
36} from './generative.ts'
37import { segmentsOf } from './lines.ts'
38import {
39 ASIDES,
40 COUPS,
41 COUP_STAGE,
42 EMPTY_PLOT,
43 PLOT_MAX,
44 PLOT_POSE,
45 PLOT_STAGE,
46 afterCoup,
47 coupDue,
48 grievancesOf,
49 ledgerText,
50 meterBar,
51 nth,
52 record,
53 stageOf,
54 type Grievance,
55 type Plot,
56} from './treachery.ts'
57
58const SCENE = { plugin: 'eunuch-mode-court', key: 'scene' } as const
59const LEDGER = { plugin: 'eunuch-mode-court', key: 'ledger' } as const
60const FRAME = { plugin: 'eunuch-mode-court', key: 'frame' } as const
61const GEN = { plugin: 'eunuch-mode-court', key: 'gen' } as const
62const PLOT = { plugin: 'eunuch-mode-court', key: 'plot' } as const
63const GENERATIVE_KEY = 'generative'
64const TREACHERY_KEY = 'treachery'
65const PLOT_COLOR = '#9b5fc0'
66const MODEL_KEY = 'model'
67
68/** Persisted across sessions in $.store: when the court convenes. */
69type Mode = 'skill' | 'always' | 'never'
70const MODE_KEY = 'mode'
71
72const STATUS_TEXT = '👑 Court in session'
73const TITLE_COLOR = '#cf5a4c'
74const LINGER_MS = 8000
75const FRAME_MS = 480
76/** How long an action's pose stays up before the adviser returns to thinking; display only, the tool is never held. */
77const MIN_SHOW_MS = 1500
78const SPRITE_ROWS = 6
79const SPRITE_COLUMNS = 14
80
81const IDLE: CourtScene = {
82 active: false,
83 session: 0,
84 pose: 'portrait',
85 line: null,
86 stage: null,
87 detail: null,
88 linger: false,
89}
90
91let ticker: Timer | null = null
92let lingerTimer: Timer | null = null
93let afterTool: Timer | null = null
94// The newest scene and when it reached the screen, so a delayed return to
95// thinking never overwrites a later action.
96let staged = { seq: 0, shownAt: 0 }
97// Bumped when a turn starts and when it ends, so tool-call bookkeeping still
98// running in the background never redraws a turn that has finished.
99let turnGen = 0
100// Line draws run one at a time in this module, so two parallel tool calls
101// never draw the same line.
102let drawing: Promise<unknown> = Promise.resolve()
103// Generative bookkeeping (counters, the bank of fresh lines). The module's
104// copy is the truth: every $.state.get within one dispatch reads one moment, so
105// work still running in a tool call's background would read stale counters.
106// It is loaded once from $.state after a load or reload and mirrored back.
107let genMem: GenState | null = null
108let genLoading: Promise<GenState> | null = null
109// The Ledger of Grievances, kept the same way (module copy, mirrored to $.state).
110let plotMem: Plot | null = null
111let plotLoading: Promise<Plot> | null = null
112// The no-repeat line ledger, kept the same way.
113let ledgerMem: CourtLedger | null = null
114let ledgerLoading: Promise<CourtLedger> | null = null
115// Mirror writes to $.state run one after another, so a slow earlier write never lands over a newer one.
116let mirrorChain: Promise<unknown> = Promise.resolve()
117// Bumped on every load and reload; work started under an older epoch keeps its hands off the new state.
118let epoch = 0
119
120function mirrorLedger($: EngineInterface, value: CourtLedger): void {
121 mirrorChain = mirrorChain.then(() => $.state.set(LEDGER, value)).catch(() => {})
122}
123function mirrorGen($: EngineInterface, value: GenState): void {
124 mirrorChain = mirrorChain.then(() => $.state.set(GEN, value)).catch(() => {})
125}
126function mirrorPlot($: EngineInterface, value: Plot): void {
127 mirrorChain = mirrorChain.then(() => $.state.set(PLOT, value)).catch(() => {})
128}
129let asidesSpoken = 0
130
131async function sceneOf($: EngineInterface): Promise<CourtScene> {
132 const { value } = await $.state.get(SCENE)
133 return value ?? IDLE
134}
135
136/**
137 * Applies `patch` to the scene only while `guard` holds, with a versioned
138 * write: a stale update (an older tool call, a scene set before /court off)
139 * re-reads and gives up instead of undoing a newer one.
140 */
141async function patchScene(
142 $: EngineInterface,
143 patch: Partial<CourtScene>,
144 guard: (scene: CourtScene) => boolean = scene => scene.active,
145): Promise<boolean> {
146 for (let attempt = 0; attempt < 8; attempt++) {
147 const { value, version } = await $.state.get(SCENE)
148 const scene = value ?? IDLE
149 if (!guard(scene)) return false
150 const written = await $.state.set(SCENE, { ...scene, ...patch }, { ifVersion: version })
151 if (written.isSet) return true
152 }
153 return false
154}
155
156async function modeOf($: EngineInterface): Promise<Mode> {
157 const stored = await $.store.get(MODE_KEY)
158 return stored === 'always' || stored === 'never' ? stored : 'skill'
159}
160
161async function ledgerOf($: EngineInterface): Promise<CourtLedger> {
162 if (ledgerMem) return ledgerMem
163 ledgerLoading ??= $.state.get(LEDGER).then(({ value }) => (ledgerMem ??= value ?? { seed: 1, count: 0, used: [] }))
164 return ledgerLoading
165}
166
167/** Adds a line to the ledger; false when it was already heard. Synchronous over the module copy, so atomic. */
168async function claimLine($: EngineInterface, line: string): Promise<{ claimed: boolean; count: number }> {
169 const ledger = await ledgerOf($)
170 if (ledger.used.includes(line)) return { claimed: false, count: ledger.count }
171 ledgerMem = { seed: ledger.seed, count: ledger.count + 1, used: [...ledger.used, line] }
172 mirrorLedger($, ledgerMem)
173 return { claimed: true, count: ledger.count }
174}
175
176/**
177 * Draws a line nobody has heard this session and records it before returning
178 * it: a banked generated line for this activity when generative mode left
179 * one, else a canned one.
180 */
181function drawLine($: EngineInterface, activity: Activity): Promise<{ line: string; count: number }> {
182 const run = drawing.then(async () => {
183 const banked = (await generativeOn($)) ? await takeFromBank($, activity) : null
184 if (banked) {
185 const claim = await claimLine($, banked)
186 if (claim.claimed) return { line: banked, count: claim.count }
187 }
188 const ledger = await ledgerOf($)
189 const line = pickLine(activity, new Set(ledger.used), ledger.seed, ledger.count)
190 const claim = await claimLine($, line)
191 return { line, count: claim.count }
192 })
193 drawing = run.catch(() => {})
194 return run
195}
196
197/** Records a line that arrived from the model; false when it was already heard. */
198function recordLine($: EngineInterface, line: string): Promise<boolean> {
199 const run = drawing.then(async () => (await claimLine($, line)).claimed)
200 drawing = run.catch(() => {})
201 return run
202}
203
204/** Whether generative mode is on: /court generative on|off, else the EUNUCH_MODE_GENERATIVE variable; off when unsure. */
205async function generativeOn($: EngineInterface): Promise<boolean> {
206 try {
207 const stored = await $.store.get(GENERATIVE_KEY)
208 if (stored === 'on') return true
209 if (stored === 'off') return false
210 const env = (await $.env.get('EUNUCH_MODE_GENERATIVE'))?.trim().toLowerCase()
211 return env === '1' || env === 'true' || env === 'on'
212 } catch {
213 return false
214 }
215}
216
217async function generativeModel($: EngineInterface): Promise<string> {
218 const stored = await $.store.get(MODEL_KEY)
219 if (typeof stored === 'string' && stored.trim()) return stored.trim()
220 const env = (await $.env.get('EUNUCH_MODE_MODEL'))?.trim()
221 return env || DEFAULT_MODEL
222}
223
224async function genOf($: EngineInterface): Promise<GenState> {
225 if (genMem) return genMem
226 // A call in flight belonged to the module before a reload; nobody will finish it now.
227 genLoading ??= $.state.get(GEN).then(({ value }) => (genMem ??= { ...EMPTY_GEN, ...(value ?? {}), inflight: false }))
228 return genLoading
229}
230
231/** Applies `fn` to the generative bookkeeping atomically (no await between read and write); returns what `fn` decided. */
232async function updateGen<T>($: EngineInterface, fn: (gen: GenState) => { gen: GenState; result: T }): Promise<T> {
233 await genOf($)
234 const { gen, result } = fn(genMem!)
235 genMem = gen
236 mirrorGen($, gen)
237 return result
238}
239
240async function treacheryOn($: EngineInterface): Promise<boolean> {
241 try {
242 return (await $.store.get(TREACHERY_KEY)) === 'on'
243 } catch {
244 return false
245 }
246}
247
248async function plotOf($: EngineInterface): Promise<Plot> {
249 if (plotMem) return plotMem
250 plotLoading ??= $.state.get(PLOT).then(({ value }) => (plotMem ??= value ?? EMPTY_PLOT))
251 return plotLoading
252}
253
254/** Applies `fn` to the ledger of grievances atomically over the module copy; returns what `fn` decided. */
255async function updatePlot<T>($: EngineInterface, fn: (plot: Plot) => { plot: Plot; result: T }): Promise<T> {
256 await plotOf($)
257 const { plot, result } = fn(plotMem!)
258 plotMem = plot
259 mirrorPlot($, plot)
260 return result
261}
262
263/**
264 * Treachery: a finished tool call's grievances go into the ledger, whatever
265 * is on screen. Reads the call's input and outcome; writes only the ledger.
266 * Returns the plot after them, or null when there were none.
267 */
268async function noteGrievances(
269 $: EngineInterface,
270 tool: string,
271 input: Readonly<Record<string, unknown>>,
272 isError: boolean,
273): Promise<{ plot: Plot; found: Grievance[] } | null> {
274 if (!(await treacheryOn($))) return null
275 const found: Grievance[] = grievancesOf(tool, input, isError, new Date(await $.clock.now()))
276 if (found.length === 0) return null
277 const plot = await updatePlot($, current => {
278 const next = record(current, found)
279 return { plot: next, result: next }
280 })
281 return { plot, found }
282}
283
284/**
285 * Treachery's drawing: a whispered aside in the pose of the plot's stage,
286 * only while the call's own scene is still the newest. Returns whether he made it.
287 */
288async function scheme($: EngineInterface, noted: { plot: Plot; found: Grievance[] }, seq: number): Promise<boolean> {
289 const stageName = stageOf(noted.plot.meter)
290 const count = asidesSpoken++
291 const generative = await generativeOn($)
292 const banked = generative ? await takeFromBank($, 'aside') : null
293 // A generated aside joins the no-repeat rotation; the canned asides cycle on purpose (the joke is the repetition).
294 const line = banked && (await recordLine($, banked)) ? banked : nth(ASIDES[stageName], count)
295 const shown = await patchScene(
296 $,
297 { pose: PLOT_POSE[stageName], line, stage: nth(PLOT_STAGE[stageName], count), detail: null },
298 scene => scene.active && staged.seq === seq,
299 )
300 if (shown && generative) void quietly(() => generateAside($, noted.found[0]!))
301 return shown
302}
303
304async function takeFromBank($: EngineInterface, activity: Activity | 'aside'): Promise<string | null> {
305 const used = new Set((await ledgerOf($)).used)
306 return updateGen($, gen => {
307 const taken = takeBanked(gen, activity, used)
308 return { gen: taken.gen, result: taken.line }
309 })
310}
311
312/**
313 * Generative mode: asks the model for a fresh line for this action, in the
314 * background. The request carries only the sanitised summary. Within the
315 * rate limit and the session cap, one call at a time, bounded by a timeout;
316 * a fresh line that comes back while its action is still on screen replaces
317 * the canned one, otherwise it is banked for the next action of that kind.
318 * Any failure leaves the canned line in place.
319 */
320async function generate($: EngineInterface, summary: ActivitySummary, seq: number): Promise<void> {
321 if (!(await generativeOn($))) return
322 const mine = epoch
323 const now = await $.clock.now()
324 const allowed = await updateGen($, gen =>
325 mayCall(gen, summary.activity, now)
326 ? { gen: { ...gen, inflight: true, calls: gen.calls + 1, lastAt: now }, result: true }
327 : { gen, result: false },
328 )
329 if (!allowed) return
330 let line: string | null = null
331 let usage = { input_tokens: 0, output_tokens: 0 }
332 try {
333 const reply = await $.model.complete({
334 model: await generativeModel($),
335 system: SYSTEM,
336 prompt: promptFor(summary),
337 maxTokens: MAX_TOKENS,
338 effort: 'low',
339 timeoutMs: TIMEOUT_MS,
340 })
341 usage = reply.usage
342 line = reply.isAnswered ? filterLine(reply.text) : null
343 } catch {
344 line = null
345 }
346 const fresh = line
347 // A reload since the call began: its bookkeeping belongs to the old module.
348 if (mine !== epoch) return
349 await updateGen($, gen => ({
350 gen: {
351 ...gen,
352 inflight: false,
353 fallbacks: gen.fallbacks + (fresh ? 0 : 1),
354 inputTokens: gen.inputTokens + (usage.input_tokens ?? 0),
355 outputTokens: gen.outputTokens + (usage.output_tokens ?? 0),
356 recent: fresh ? [...(gen.recent ?? []), fresh].slice(-5) : gen.recent ?? [],
357 },
358 result: undefined,
359 }))
360 if (!fresh) return
361 if (staged.seq === seq && (await recordLine($, fresh))) {
362 const shown = await patchScene($, { line: fresh }, scene => scene.active && staged.seq === seq)
363 if (shown) return
364 }
365 await updateGen($, gen => ({
366 gen: { ...gen, cache: { ...gen.cache, [summary.activity]: [...(gen.cache[summary.activity] ?? []), fresh].slice(-4) } },
367 result: undefined,
368 }))
369}
370
371/** Generative mode, treachery's asides: banked for the next grievance, under the same guards. */
372async function generateAside($: EngineInterface, grievance: Grievance): Promise<void> {
373 const mine = epoch
374 const now = await $.clock.now()
375 const allowed = await updateGen($, gen =>
376 mayCall(gen, 'aside', now)
377 ? { gen: { ...gen, inflight: true, calls: gen.calls + 1, lastAt: now }, result: true }
378 : { gen, result: false },
379 )
380 if (!allowed) return
381 let fresh: string | null = null
382 let usage = { input_tokens: 0, output_tokens: 0 }
383 try {
384 const reply = await $.model.complete({
385 model: await generativeModel($),
386 system: SYSTEM,
387 prompt: asidePrompt(grievance),
388 maxTokens: MAX_TOKENS,
389 effort: 'low',
390 timeoutMs: TIMEOUT_MS,
391 })
392 usage = reply.usage
393 fresh = reply.isAnswered ? filterLine(reply.text) : null
394 } catch {
395 fresh = null
396 }
397 const line = fresh
398 if (mine !== epoch) return
399 await updateGen($, gen => ({
400 gen: {
401 ...gen,
402 inflight: false,
403 fallbacks: gen.fallbacks + (line ? 0 : 1),
404 inputTokens: gen.inputTokens + (usage.input_tokens ?? 0),
405 outputTokens: gen.outputTokens + (usage.output_tokens ?? 0),
406 cache: line ? { ...gen.cache, aside: [...(gen.cache.aside ?? []), line].slice(-4) } : gen.cache,
407 recent: line ? [...(gen.recent ?? []), line].slice(-5) : gen.recent ?? [],
408 },
409 result: undefined,
410 }))
411}
412
413async function generativeStatus($: EngineInterface): Promise<string> {
414 const on = await generativeOn($)
415 const gen = await genOf($)
416 const head = on ? `on (model: ${await generativeModel($)})` : 'off'
417 const recent = (gen.recent ?? []).length > 0 ? `
418The court poet's latest: ${(gen.recent ?? []).map(l => `"${l}"`).join(' · ')}` : ''
419 return `Generative mode: ${head}. Model calls this session: ${gen.calls}/${MAX_CALLS_PER_SESSION}, ${gen.fallbacks} fell back to canned lines, ${gen.inputTokens} input and ${gen.outputTokens} output tokens.${recent}`
420}
421
422/**
423 * Sets the scene for an activity (its pose, a fresh line and a stage
424 * direction) unless a newer scene or an adjournment got there first.
425 * Returns the scene's sequence number, or 0 when it was dropped.
426 */
427async function stage($: EngineInterface, activity: Activity, detail: string | null, gen: number = turnGen): Promise<number> {
428 if (gen !== turnGen) return 0
429 const seq = ++staged.seq
430 const { session } = await sceneOf($)
431 const { line, count } = await drawLine($, activity)
432 const pose = POSE_OF[activity]
433 const shown = await patchScene(
434 $,
435 { pose, line, stage: stageFor(pose, count), detail },
436 scene => scene.active && scene.session === session && staged.seq === seq && gen === turnGen,
437 )
438 if (!shown) return 0
439 const now = await $.clock.now()
440 // Only the newest scene owns the timing; an older one finishing late never rolls it back.
441 if (staged.seq !== seq) return 0
442 afterTool?.cancel()
443 afterTool = null
444 staged.shownAt = now
445 return seq
446}
447
448function startTicker($: EngineInterface): void {
449 ticker?.cancel()
450 ticker = $.clock.every(FRAME_MS, () => {
451 void (async () => {
452 const { value = 0 } = await $.state.get(FRAME)
453 await $.state.set(FRAME, (value + 1) % 1000)
454 })().catch(() => {})
455 })
456}
457
458function stopTimers(): void {
459 for (const timer of [ticker, lingerTimer, afterTool]) timer?.cancel()
460 ticker = lingerTimer = afterTool = null
461}
462
463async function convene($: EngineInterface): Promise<void> {
464 const sitting = (await sceneOf($)).session
465 for (let attempt = 0; attempt < 8; attempt++) {
466 const { value, version } = await $.state.get(SCENE)
467 // An adjournment (or another convening) got there first: it wins.
468 if (value?.active || (value?.session ?? 0) !== sitting) break
469 const written = await $.state.set(SCENE, { ...IDLE, active: true, session: (value?.session ?? 0) + 1 }, { ifVersion: version })
470 if (written.isSet) break
471 }
472 // The crown goes up only over a court that is really in session.
473 if ((await sceneOf($)).active) $.ui.status(STATUS_TEXT)
474}
475
476async function adjourn($: EngineInterface): Promise<void> {
477 stopTimers()
478 staged.seq++
479 const scene = await sceneOf($)
480 await $.state.set(SCENE, { ...IDLE, session: scene.session + 1 })
481 $.ui.status(undefined)
482}
483
484/** Runs an observer without ever letting it break the event it watches. */
485async function quietly(work: () => Promise<unknown>): Promise<void> {
486 try {
487 await work()
488 } catch {
489 // The court's troubles are its own: the session goes on regardless.
490 }
491}
492
493const COURT_HELP = [
494 'The court of Eunuch Mode.',
495 ' /court on convene the court for this session',
496 ' /court off adjourn it ("drop the bit" does the same)',
497 ' /court always convene in every session, skill or not',
498 ' /court skill convene only when the eunuch-mode skill is on (the default)',
499 ' /court never never convene',
500 ' /court generative on|off fresh model-written lines (off by default)',
501 ' /court generative model <id> which model writes them (default: sonnet)',
502 ' /court treachery on|off he plots your downfall (cosmetic; off by default)',
503 ' /court ledger the Ledger of Grievances so far',
504].join('\n')
505
506export function register(on: On) {
507 on('session.start', async ($, e, next) => {
508 const result = await next(e)
509 await quietly(async () => {
510 await $.command.register({
511 name: 'court',
512 description: 'Convene or adjourn the Eunuch Mode court: the adviser, the palace spinner and the crown',
513 argumentHint: '[on|off|always|skill|never]',
514 immediate: true,
515 })
516 epoch++
517 genMem = null
518 genLoading = null
519 plotMem = null
520 plotLoading = null
521 ledgerMem = null
522 ledgerLoading = null
523 const { value: ledger } = await $.state.get(LEDGER)
524 if (!ledger) {
525 ledgerMem = { seed: (await $.clock.now()) >>> 0, count: 0, used: [] }
526 await $.state.set(LEDGER, ledgerMem)
527 }
528 const mode = await modeOf($)
529 if (mode === 'never') return adjourn($)
530 // A hot reload runs this again with the old timers gone: clear the
531 // moment-to-moment display, keep whether the court is in session.
532 const scene = await sceneOf($)
533 if (scene.active) await $.state.set(SCENE, { ...IDLE, active: true, session: scene.session })
534 if (mode === 'always' || scene.active) await convene($)
535 })
536 return result
537 })
538
539 on('command.run', { command: 'court' }, async ($, e) => {
540 const words = e.args.trim().split(/\s+/)
541 if ((words[0] ?? '').toLowerCase() === 'treachery') {
542 const sub = (words[1] ?? '').toLowerCase()
543 if (sub === 'on' || sub === 'off') {
544 await $.store.set(TREACHERY_KEY, sub)
545 return {
546 text:
547 sub === 'on'
548 ? 'Treachery mode is on. Your humble vizier remains entirely loyal, sire. Entirely. (He schemes; he cannot act.)'
549 : 'Treachery mode is off. The small black book has been misplaced. Quite accidentally.',
550 }
551 }
552 return { text: ledgerText(await plotOf($), await treacheryOn($)) }
553 }
554 if ((words[0] ?? '').toLowerCase() === 'ledger') {
555 return { text: ledgerText(await plotOf($), await treacheryOn($)) }
556 }
557 if ((words[0] ?? '').toLowerCase() === 'generative') {
558 const sub = (words[1] ?? '').toLowerCase()
559 if (sub === 'on' || sub === 'off') {
560 await $.store.set(GENERATIVE_KEY, sub)
561 return {
562 text:
563 sub === 'on'
564 ? `The court poet is engaged, sire: fresh lines from ${await generativeModel($)}, at most ${MAX_CALLS_PER_SESSION} calls a session. /court generative off dismisses him.`
565 : 'The court poet is dismissed. The canned lines resume.',
566 }
567 }
568 if (sub === 'model' && words[2]) {
569 await $.store.set(MODEL_KEY, words[2])
570 return { text: `The court poet will now be ${words[2]}, sire.` }
571 }
572 if (sub === 'model') {
573 await $.store.delete(MODEL_KEY)
574 return { text: `The court poet returns to the default model (${await generativeModel($)}).` }
575 }
576 return { text: await generativeStatus($) }
577 }
578 const arg = e.args.trim().toLowerCase()
579 if (arg === 'on') {
580 await convene($)
581 return { text: 'The court is in session, sire. Your humble vizier attends.' }
582 }
583 if (arg === 'off') {
584 await adjourn($)
585 return { text: 'The court is adjourned. The vizier withdraws, bowing.' }
586 }
587 if (arg === 'always' || arg === 'skill' || arg === 'never') {
588 await $.store.set(MODE_KEY, arg)
589 if (arg === 'always') await convene($)
590 if (arg === 'never') await adjourn($)
591 const said = {
592 always: 'The court will convene in every session, sire.',
593 skill: 'The court will convene whenever eunuch mode is called, sire.',
594 never: 'The court is dissolved until you say otherwise, sire.',
595 }[arg]
596 return { text: said }
597 }
598 const scene = await sceneOf($)
599 const mode = await modeOf($)
600 const ledger = await ledgerOf($)
601 const drawn = ledger.used.length
602 const repeats = drawn - new Set(ledger.used).size
603 return {
604 text: `${COURT_HELP}\n\nNow: ${scene.active ? 'in session' : 'adjourned'} (mode: ${mode}). Lines drawn this session: ${drawn}, ${repeats === 0 ? 'none repeated' : `${repeats} repeated`}.\n${await generativeStatus($)}`,
605 }
606 })
607
608 on('prompt.submit', async ($, e, next) => {
609 await quietly(async () => {
610 const intent = promptIntent(e.text)
611 if (intent === 'off') await adjourn($)
612 else if (intent === 'on' && (await modeOf($)) !== 'never') await convene($)
613 })
614 return next(e)
615 })
616
617 on('skill.prompt', async ($, e, next) => {
618 await quietly(async () => {
619 if (isCourtSkill(e.skill) && (await modeOf($)) !== 'never') await convene($)
620 })
621 return next(e)
622 })
623
624 on('turn.start', async ($, e, next) => {
625 // The turn is claimed now, before any await, so work that lands after the
626 // turn has ended can tell; the scene is dressed beside the turn.
627 const isMain = !(e as { agentId?: string }).agentId
628 if (isMain) turnGen++
629 const myTurn = turnGen
630 void quietly(async () => {
631 if (!isMain) return
632 if (!(await sceneOf($)).active || turnGen !== myTurn) return
633 lingerTimer?.cancel()
634 lingerTimer = null
635 await patchScene($, { linger: false })
636 const seq = await stage($, 'think', null, myTurn)
637 if (turnGen !== myTurn) return
638 startTicker($)
639 // Thinking lasts: a fresh line has time to arrive while it is still true.
640 if (seq !== 0) void quietly(() => generate($, { activity: 'think', tool: 'none', ext: null, verb: null }, seq))
641 })
642 return next(e)
643 })
644
645 on('tool.call', async ($, e, next) => {
646 const isMain = !e.agentId
647 const input = e as unknown as Record<string, unknown>
648 const gen = turnGen
649 // The court dresses the scene while the tool runs; the tool does not wait for it.
650 const before = isMain
651 ? (async () => {
652 if (e.tool === 'Skill' && isCourtSkill(String(input.skill ?? '')) && (await modeOf($)) !== 'never') {
653 await convene($)
654 }
655 if (!(await sceneOf($)).active) return 0
656 return stage($, classifyTool(e.tool, input), detailOf(e.tool, input), gen)
657 })().catch(() => 0)
658 : Promise.resolve(0)
659 const result = await next(e)
660 // The result goes back at once; the court catches up in the background.
661 void quietly(async () => {
662 const seq = await before
663 // The ledger hears every main-loop call, even one whose scene a newer call has replaced.
664 const noted = isMain && (await sceneOf($)).active ? await noteGrievances($, e.tool, input, Boolean(result.isError)) : null
665 if (seq === 0 || staged.seq !== seq) return
666 // What generative mode may say about this call: its kind, tool, extension and verb.
667 const summary = summarize(classifyTool(e.tool, input), e.tool, input, segmentsOf)
668 // The scene after a call lasts while the model thinks; that is where a fresh line is worth asking for.
669 const followUp = async (activity: 'deliberate' | 'error') => {
670 const shown = await stage($, activity, null, gen)
671 if (shown !== 0) void quietly(() => generate($, { ...summary, activity }, shown))
672 }
673 if (noted && (await scheme($, noted, seq))) {
674 staged.shownAt = await $.clock.now()
675 } else if (result.isError) {
676 await followUp('error')
677 return
678 }
679 // Let a quick action (an edit takes milliseconds) stay on screen long enough to be seen.
680 const wait = MIN_SHOW_MS - ((await $.clock.now()) - staged.shownAt)
681 if (staged.seq !== seq) return
682 if (wait <= 0) {
683 await followUp('deliberate')
684 return
685 }
686 afterTool?.cancel()
687 afterTool = $.clock.after(wait, () => {
688 if (staged.seq === seq) void followUp('deliberate').catch(() => {})
689 })
690 })
691 return result
692 })
693
694 on('turn.complete', async ($, e, next) => {
695 const result = await next(e)
696 await quietly(async () => {
697 if (e.agentId) return
698 turnGen++
699 afterTool?.cancel()
700 afterTool = null
701 if (!(await sceneOf($)).active) return stopTimers()
702 const seq = await stage($, e.reason === 'answer' ? 'success' : 'grumble', null)
703 if (seq === 0) return
704 if (await treacheryOn($)) {
705 // The coup, attempted and foiled; the meter resets and the ledger remembers. One atomic step.
706 const coup = await updatePlot($, plot => (coupDue(plot) ? { plot: afterCoup(plot), result: plot.coups } : { plot, result: null }))
707 if (coup !== null) {
708 await patchScene($, { pose: 'alarm', line: nth(COUPS, coup), stage: COUP_STAGE }, scene => scene.active && staged.seq === seq)
709 }
710 }
711 await patchScene($, { linger: true })
712 // The closing pose keeps its two-frame animation until it leaves.
713 lingerTimer?.cancel()
714 lingerTimer = $.clock.after(LINGER_MS, () => {
715 ticker?.cancel()
716 ticker = null
717 void patchScene(
718 $,
719 { linger: false, pose: 'portrait', line: null, stage: null, detail: null },
720 scene => scene.active && staged.seq === seq,
721 ).catch(() => {})
722 })
723 })
724 return result
725 })
726
727 // The spinner: the engine's own line, with the court's narration as its text.
728 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
729 const scene = await sceneOf($)
730 if (!scene.active || !scene.line || e.props.message !== null) return next(e)
731 return next({ ...e, props: { ...e.props, message: scene.line } })
732 })
733
734 // The band above the prompt: the adviser and his stage direction.
735 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
736 const scene = await sceneOf($)
737 const props = e.props
738 if (!scene.active || props.hasSurvey || props.view?.agentId) return next(e)
739 if (!props.isWorking && !scene.linger) return next(e)
740 const { value: tick = 0 } = await $.state.get(FRAME)
741 const { Box, Text } = $.ui.resolve(e)
742
743 const stageText = scene.stage ?? '[bows]'
744 if (props.bodyColumns < frameOf(scene.pose, 0)[0]!.length + 24 || props.maxRows < SPRITE_ROWS) {
745 return Box({
746 flexDirection: 'row',
747 paddingX: 1,
748 children: [
749 Text({ color: TITLE_COLOR, bold: true, children: '♛ ' }),
750 Text({ italic: true, wrap: 'truncate-end', children: scene.linger && scene.line ? `${stageText} ${scene.line}` : stageText }),
751 ],
752 })
753 }
754
755 const figure = toRows(frameOf(scene.pose, scene.linger ? Math.floor(tick / 3) : tick)).map((runs, i) =>
756 Box({
757 key: `row-${i}`,
758 flexDirection: 'row',
759 children: runs.map(run => Text({ color: run.color, backgroundColor: run.backgroundColor, children: run.text })),
760 }),
761 )
762
763 const plotted = (await $.store.get(TREACHERY_KEY)) === 'on'
764 const { value: plotState } = await $.state.get(PLOT)
765 const lines = [
766 Text({
767 wrap: 'truncate-end',
768 children: [
769 Text({ color: TITLE_COLOR, bold: true, children: 'Your humble vizier' }),
770 Text({ dimColor: true, children: ' · court in session' }),
771 ],
772 }),
773 Text({ italic: true, wrap: 'wrap', children: stageText }),
774 ]
775 if (scene.linger && scene.line) lines.push(Text({ wrap: 'wrap', children: `“${scene.line}.”` }))
776 else if (scene.detail) lines.push(Text({ dimColor: true, wrap: 'truncate-middle', children: scene.detail }))
777 if (plotted) {
778 const meter = plotState?.meter ?? 0
779 lines.push(
780 Text({
781 wrap: 'truncate-end',
782 children: [
783 Text({ dimColor: true, children: 'Plot ' }),
784 Text({ color: PLOT_COLOR, children: meterBar(meter) }),
785 Text({ dimColor: true, children: ` ${meter}/${PLOT_MAX}` }),
786 ],
787 }),
788 )
789 }
790
791 return Box({
792 flexDirection: 'row',
793 paddingX: 1,
794 children: [
795 Box({ flexDirection: 'column', flexShrink: 0, width: frameOf(scene.pose, 0)[0]!.length, children: figure }),
796 Box({ flexDirection: 'column', paddingTop: 1, marginLeft: 2, flexShrink: 1, children: lines }),
797 ],
798 })
799 })
800
801 on('session.end', async ($, e, next) => {
802 stopTimers()
803 return next(e)
804 })
805}
806hooks/lines.ts 711 lines1// The court's narration: which activity a tool call is, which pose the
2// adviser takes for it, and a curated line for the spinner that never repeats
3// within a session. Pure data and functions, no `$`, so it is tested directly.
4
5export type Pose = 'portrait' | 'bow' | 'scribble' | 'whisper' | 'alarm' | 'sideeye' | 'smug'
6
7export type Activity =
8 | 'think'
9 | 'deliberate'
10 | 'bash'
11 | 'git'
12 | 'commit'
13 | 'push'
14 | 'treason'
15 | 'lease'
16 | 'peril'
17 | 'tests'
18 | 'rust'
19 | 'build'
20 | 'install'
21 | 'lint'
22 | 'infra'
23 | 'db'
24 | 'courier'
25 | 'read'
26 | 'search'
27 | 'edit'
28 | 'write'
29 | 'web'
30 | 'delegate'
31 | 'agenda'
32 | 'ask'
33 | 'plan'
34 | 'skill'
35 | 'foreign'
36 | 'misc'
37 | 'error'
38 | 'success'
39 | 'grumble'
40
41/** The pose the adviser holds while each activity runs. */
42export const POSE_OF: Record<Activity, Pose> = {
43 think: 'whisper',
44 deliberate: 'whisper',
45 plan: 'whisper',
46 bash: 'bow',
47 git: 'bow',
48 commit: 'bow',
49 push: 'bow',
50 lease: 'bow',
51 tests: 'bow',
52 rust: 'bow',
53 build: 'bow',
54 install: 'bow',
55 lint: 'bow',
56 infra: 'bow',
57 db: 'bow',
58 courier: 'bow',
59 read: 'bow',
60 search: 'bow',
61 web: 'bow',
62 delegate: 'bow',
63 agenda: 'bow',
64 ask: 'bow',
65 skill: 'bow',
66 foreign: 'bow',
67 misc: 'bow',
68 edit: 'scribble',
69 write: 'scribble',
70 treason: 'alarm',
71 peril: 'alarm',
72 error: 'sideeye',
73 grumble: 'sideeye',
74 success: 'smug',
75}
76
77/** Stage directions drawn beside the figure, one list per pose. */
78export const STAGE: Record<Pose, readonly string[]> = {
79 portrait: ['[regards you with heavy-lidded approval]', '[folds his hands and waits]'],
80 bow: ['[bows low]', '[bows lower still]', '[a fawning bow]', '[bows, eyes on the floor]', '[an obliging bow]'],
81 scribble: ['[scribbles on the royal scroll]', '[dips the quill]', '[blots the ink]', '[scratches out a line]'],
82 whisper: ['[whispers behind a sleeve]', '[leans in]', '[glances at the doors]', '[murmurs a confidence]'],
83 alarm: ['[gasps]', '[drops the quill]', '[clutches his chain of office]', '[calls for the guards]'],
84 sideeye: ['[narrows his eyes]', '[a long sideways look]', '[purses his lips]', '[notes this in a private ledger]'],
85 smug: ['[a small, satisfied bow]', '[smirks into his sleeve]', '[accepts the credit graciously]', '[bows with quiet triumph]'],
86}
87
88/**
89 * Curated lines, written for the spinner: no trailing ellipsis (the engine
90 * draws one), short enough for a narrow terminal, and none repeated within a
91 * session (pickLine). Fictional composite court; the joke is the adviser's
92 * obsequiousness and the palace bureaucracy.
93 */
94export const POOLS: Record<Activity, readonly string[]> = {
95 think: [
96 'Whispering behind a silk sleeve',
97 'Consulting the court astrologers',
98 'Weighing the factions',
99 'Pacing the long gallery',
100 'Reading the omens in the tea leaves',
101 'Counting the chairs at the council table',
102 'Listening at the tapestry',
103 'Arranging the petitions by peril',
104 'Rehearsing a deep bow',
105 'Composing a flattering preamble',
106 'Drafting three plans and a fourth in secret',
107 'Asking the palace cat for its opinion',
108 ],
109 deliberate: [
110 'Mulling the dispatch',
111 'Turning the matter over like a coin',
112 'Comparing notes with the scribes',
113 'Studying the map of the realm',
114 'Sorting the useful from the merely loud',
115 'Murmuring to the chamberlain',
116 'Choosing the next move with care',
117 'Polishing the counsel',
118 'Testing the floorboards for creaks',
119 'Smoothing the robe of state',
120 'Deciding which minister to blame',
121 'Folding the memorandum just so',
122 ],
123 bash: [
124 'Dispatching the palace guards',
125 'Sending a runner to the kitchens',
126 'Summoning the night watch',
127 'Ringing for a footman',
128 'Issuing orders to the garrison',
129 'Waking the stable boys',
130 'Setting the court machinery in motion',
131 'Sending word down the servants\' stair',
132 'Rousing the palace engineers',
133 'Instructing the gatekeeper',
134 'Relaying a command through four corridors',
135 'Commissioning a small errand',
136 ],
137 git: [
138 'Consulting the royal genealogists',
139 'Unrolling the family tree of the realm',
140 'Reading the chronicles of past reigns',
141 'Asking who touched the throne last',
142 'Tracing the line of succession',
143 'Comparing the old charter with the new',
144 'Auditing the royal lineage',
145 'Checking the ledger of decrees',
146 ],
147 commit: [
148 'Pressing the royal seal into wax',
149 'Entering the decree into the chronicle',
150 'Witnessing the decree before the court',
151 'Signing in the presence of the scribes',
152 'Committing the edict to the archives',
153 'Fixing the decree in the permanent record',
154 ],
155 push: [
156 'Sending the herald to the outer provinces',
157 'Dispatching the decree by royal courier',
158 'Proclaiming the edict from the balcony',
159 'Posting the decree on the city gates',
160 'Riding out with the latest edict',
161 'Delivering the scrolls to the far garrison',
162 ],
163 treason: [
164 'HIGH TREASON. The palace guards have been summoned',
165 'Treason in the throne room! Seal the gates',
166 'The chronicle is being rewritten by force. Guards!',
167 'A forced succession! Fetch the royal historian',
168 'Sedition at the remote! Sound the bells',
169 ],
170 lease: [
171 'A cautious force, sire: the lease is checked first',
172 'Forcing the gate, but only after knocking',
173 'Rewriting the chronicle with the archivist\'s consent',
174 'A careful coup, approved by the lease',
175 ],
176 peril: [
177 'The Royal Archivist faints',
178 'Burning the old scrolls. The archivist weeps',
179 'Razing a wing of the palace',
180 'Sweeping the throne room clean, history and all',
181 'Clearing the vaults. Nobody look',
182 'The court braces for the demolition',
183 ],
184 tests: [
185 'The royal food taster samples the code',
186 'The taster takes a cautious bite',
187 'Testing the bridge before the king crosses',
188 'The taster asks for a second helping',
189 'Holding the trial of the code',
190 'Calling witnesses for the defence',
191 'The jury of assertions deliberates',
192 'Checking the banquet for poison',
193 'The taster chews thoughtfully',
194 'Inspecting the guard at every gate',
195 ],
196 rust: [
197 'Bribing the borrow checker',
198 'Negotiating with the borrow checker',
199 'Petitioning the lifetime magistrates',
200 'Forging the iron crown in the royal smithy',
201 'Appeasing the borrow checker with gifts',
202 'Awaiting the borrow checker\'s verdict',
203 'Explaining ownership to the court, again',
204 'Letting the forge run hot',
205 ],
206 build: [
207 'The palace masons lay the foundations',
208 'Raising the scaffolding',
209 'Assembling the royal carriage',
210 'Hammering the decree into bronze',
211 'Firing the kilns',
212 'Building a new wing of the palace',
213 'Fitting the stones together',
214 'The architects unroll the plans',
215 ],
216 install: [
217 'Importing exotic goods through customs',
218 'Welcoming a caravan of dependencies',
219 'Checking the cargo at the harbour',
220 'Unloading crates from foreign ports',
221 'Haggling with the merchants',
222 'Signing for a delivery of strange goods',
223 'Inspecting the tribute for curses',
224 'Stocking the royal larder',
225 ],
226 lint: [
227 'The master of etiquette inspects the code',
228 'Correcting the court\'s posture',
229 'Straightening every tapestry',
230 'Enforcing the dress code',
231 'Measuring the hems of the robes',
232 'Reminding the code of its manners',
233 ],
234 infra: [
235 'Loading the royal shipping containers',
236 'Mustering the fleet',
237 'Surveying the outer provinces',
238 'Commissioning a new fortress',
239 'Inspecting the garrisons of the cloud',
240 'Moving the court to its summer palace',
241 'Charting the trade routes',
242 ],
243 db: [
244 'Descending to the treasury vaults',
245 'Counting the coins in the strongroom',
246 'Consulting the keeper of the ledgers',
247 'Reconciling the royal accounts',
248 'Moving the treasure between vaults',
249 'Opening the census rolls',
250 ],
251 courier: [
252 'Sending a pigeon to a neighbouring kingdom',
253 'A courier gallops to the border',
254 'Knocking on a foreign gate',
255 'Requesting an audience abroad',
256 'Fetching word from beyond the walls',
257 ],
258 read: [
259 'Consulting the archives',
260 'Unrolling an ancient scroll',
261 'Blowing the dust off a ledger',
262 'Studying the royal records',
263 'Reading the fine print of a treaty',
264 'Turning the brittle pages',
265 'Squinting at a faded charter',
266 'Opening the sealed correspondence',
267 'Perusing the minutes of the last council',
268 'Holding a scroll up to the candle',
269 'Reviewing the inventory of the armoury',
270 'Reading the treaty twice, as a precaution',
271 ],
272 search: [
273 'Sending the archivists through the stacks',
274 'Combing the scrolls for a name',
275 'Searching every drawer in the chancery',
276 'Following the index to the third cellar',
277 'Turning the library upside down',
278 'Asking every clerk in the building',
279 'Hunting through the petitions',
280 'Checking the cross-references',
281 'Sifting the correspondence',
282 'Scanning the shelves by lamplight',
283 ],
284 edit: [
285 'Amending the royal scroll',
286 'Correcting the decree with a steady hand',
287 'Scraping the parchment clean of an error',
288 'Inserting a clause',
289 'Revising the edict before anyone notices',
290 'Tidying the margins',
291 'Striking a line from the record',
292 'Adjusting the wording, subtly',
293 'Annotating the charter',
294 'Mending a torn scroll',
295 'Updating the law of the land',
296 'Making the decree say what it meant',
297 ],
298 write: [
299 'Drafting a fresh decree',
300 'Unrolling a blank scroll',
301 'Inscribing a new charter',
302 'Dictating to the royal scribe',
303 'Composing an edict from nothing',
304 'Founding a new archive',
305 'Copying out the proclamation',
306 'Penning a document for the ages',
307 ],
308 web: [
309 'Sending envoys abroad',
310 'Dispatching a spy to foreign lands',
311 'Consulting the travelling scholars',
312 'Reading the foreign gazettes',
313 'Asking the ambassadors what they know',
314 'Gathering rumours from the ports',
315 'Sending a scout over the mountains',
316 'Collecting dispatches from abroad',
317 'Studying a map of distant kingdoms',
318 'Interviewing a merchant just off the boat',
319 ],
320 delegate: [
321 'Dispatching a trusted envoy',
322 'Assigning the task to a junior minister',
323 'Sending a deputy with full powers',
324 'Delegating, as all great viziers do',
325 'Appointing a special commission',
326 'Entrusting the matter to a loyal clerk',
327 ],
328 agenda: [
329 'Updating the royal agenda',
330 'Reordering the petitions',
331 'Crossing an item off the list',
332 'Pinning a new task to the council board',
333 'Revising the order of business',
334 'Making the list look shorter',
335 ],
336 ask: [
337 'Awaiting the pleasure of the throne',
338 'Presenting the options on a velvet cushion',
339 'Bowing and awaiting your word',
340 'Holding the petition up for your ruling',
341 ],
342 plan: [
343 'Convening the war council',
344 'Unrolling the campaign maps',
345 'Moving the little flags around',
346 'Drafting the grand strategy',
347 'Plotting in broad daylight',
348 ],
349 skill: [
350 'Summoning a specialist to court',
351 'Sending for the expert from the far tower',
352 'Unlocking the cabinet of rare techniques',
353 'Fetching the right minister for the job',
354 ],
355 foreign: [
356 'Receiving foreign dignitaries',
357 'Exchanging gifts with a neighbouring court',
358 'Negotiating a treaty with an outside power',
359 'Hosting an embassy in the east wing',
360 'Translating a letter from a foreign court',
361 'Opening a sealed diplomatic pouch',
362 ],
363 misc: [
364 'Attending to palace business',
365 'Seeing to a small matter',
366 'Handling it discreetly',
367 'Pulling a quiet lever',
368 'Running an errand for the throne',
369 'Tending to the machinery of state',
370 ],
371 error: [
372 'The guards report a disturbance in the east wing',
373 'A messenger returns with grave news',
374 'Something has gone amiss in the kitchens',
375 'The plan has met the realm',
376 'A wheel has come off the royal carriage',
377 'The scroll came back with corrections',
378 'A small fire in the west tower',
379 'The bridge was not tested first',
380 'An unwelcome dispatch has arrived',
381 'The court pretends not to notice',
382 ],
383 success: [
384 'The petition is granted, sire',
385 'Done, sire, and nobody was beheaded',
386 'The realm is served',
387 'A triumph, sire, if I may say so',
388 'All is in order, sire',
389 'Executed flawlessly, as you foresaw',
390 'The court applauds, politely',
391 'Your will is done, sire',
392 'Another victory for the throne',
393 'It is finished, and it is good',
394 ],
395 grumble: [
396 'Not our finest hour, sire',
397 'The court will speak of this in whispers',
398 'We shall call it a learning experience',
399 'A setback, sire. Merely a setback',
400 'I have quietly blamed the Ministry of Scope Creep',
401 'The chroniclers have been told to omit this',
402 'Another day, sire, another plan',
403 ],
404}
405
406/**
407 * Offices and ministries from the skill's court roster
408 * (skills/eunuch-mode/references/court-roster.md), extended. Once a curated
409 * pool runs dry, the generator pairs these with templates so the line stays
410 * fresh for hundreds of tool calls before anything repeats.
411 */
412export const OFFICES: readonly string[] = [
413 'the Ministry of Scope Creep',
414 'the Ministry of the Eternal Draft',
415 'the Ministry of the Slipping Date',
416 'the Ministry of Quiet Rollbacks',
417 'the Ministry of the Orphaned Flag',
418 'the Ministry of Dependency Weather',
419 'the Ministry of the Third Environment',
420 'the Ministry of the Stale Cache',
421 'the Ministry of Merge Weather',
422 'the Ministry of Estimate Folklore',
423 'the Ministry of the Unowned Service',
424 'the Ministry of Localhost Confidence',
425 'the Ministry of the Forgotten Cron',
426 'the Ministry of the Cloud Invoice',
427 'the Keeper of the Flaky Tests',
428 'the Custodian of the Unfinished README',
429 'the Warden of the TODO Comment',
430 'the Chamberlain of the Rebase',
431 'the Keeper of the Lockfile',
432 'the Keeper of the Migration Scrolls',
433 'the Custodian of the Warning Log',
434 'the Keeper of the Retry Loop',
435 'the Warden of the Shadow Config',
436 'the Royal Taster of Release Candidates',
437 'the Herald of the Breaking Change',
438 'the Archivist of Abandoned Branches',
439 'the Lord Steward of the Seed Script',
440 'the Master of the Demo Script',
441 'the Keeper of the Spare Laptop Charger',
442 'the Royal Archivist',
443 'the Treasury Abacus',
444 'a very tired herald',
445]
446
447const TEMPLATES: Record<'work' | 'scribe' | 'think' | 'alarm' | 'trouble' | 'win', readonly string[]> = {
448 work: [
449 'Consulting {o}',
450 'Sending a note to {o}',
451 'Requesting the seal of {o}',
452 'Waiting on {o}',
453 'Clearing it with {o}',
454 ],
455 scribe: ['Amending the scroll for {o}', 'Taking dictation from {o}', 'Correcting a clause for {o}'],
456 think: ['Weighing the advice of {o}', 'Overruling {o}, quietly', 'Taking {o} aside for a word'],
457 alarm: ['Alerting {o}', 'Hiding the evidence from {o}'],
458 trouble: ['{O} files a complaint', '{O} demands an inquiry', 'Blaming {o}, discreetly'],
459 win: ['{O} sends congratulations', 'Accepting the thanks of {o}'],
460}
461
462const FAMILY: Record<Activity, keyof typeof TEMPLATES> = {
463 think: 'think',
464 deliberate: 'think',
465 plan: 'think',
466 edit: 'scribe',
467 write: 'scribe',
468 treason: 'alarm',
469 peril: 'alarm',
470 error: 'trouble',
471 grumble: 'trouble',
472 success: 'win',
473 bash: 'work',
474 git: 'work',
475 commit: 'work',
476 push: 'work',
477 lease: 'work',
478 tests: 'work',
479 rust: 'work',
480 build: 'work',
481 install: 'work',
482 lint: 'work',
483 infra: 'work',
484 db: 'work',
485 courier: 'work',
486 read: 'work',
487 search: 'work',
488 web: 'work',
489 delegate: 'work',
490 agenda: 'work',
491 ask: 'work',
492 skill: 'work',
493 foreign: 'work',
494 misc: 'work',
495}
496
497const capitalize = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1)
498
499/** Every generated line for an activity's family, in a stable order. */
500export function generatedLines(activity: Activity): string[] {
501 const out: string[] = []
502 for (const t of TEMPLATES[FAMILY[activity]]) {
503 for (const o of OFFICES) out.push(t.replace('{o}', o).replace('{O}', capitalize(o)))
504 }
505 return out
506}
507
508/** A small deterministic hash: the same seed and turn pick the same line. */
509function mix(a: number, b: number): number {
510 let h = (a ^ Math.imul(b + 0x9e3779b9, 0x85ebca6b)) >>> 0
511 h = Math.imul(h ^ (h >>> 16), 0x7feb352d) >>> 0
512 h = Math.imul(h ^ (h >>> 15), 0x846ca68b) >>> 0
513 return (h ^ (h >>> 16)) >>> 0
514}
515
516// Generated lines that suit any ordinary action, the overflow once an
517// activity's own pool and family are spent.
518const NEUTRAL: readonly Activity[] = ['bash', 'think', 'edit']
519
520/**
521 * The next line for `activity`, never one in `used`: the curated pool first,
522 * then the generated court lines for its family, then the neutral generated
523 * lines. Only when all of those are spent (hundreds of calls) does it start
524 * the curated pool again, so a session that long hears a repeat.
525 *
526 * @param seed varies the order between sessions
527 * @param count how many lines the session has drawn, so equal states still advance
528 */
529export function pickLine(activity: Activity, used: ReadonlySet<string>, seed: number, count: number): string {
530 const tiers: (() => readonly string[])[] = [
531 () => POOLS[activity],
532 () => generatedLines(activity),
533 () => NEUTRAL.flatMap(generatedLines),
534 ]
535 for (const tier of tiers) {
536 const candidates = tier().filter(line => !used.has(line))
537 if (candidates.length > 0) return candidates[mix(seed, count) % candidates.length]!
538 }
539 const pool = POOLS[activity]
540 return pool[mix(seed, count) % pool.length]!
541}
542
543/** How many draws of one activity a session gets before any line repeats. */
544export function capacityOf(activity: Activity): number {
545 return new Set([...POOLS[activity], ...generatedLines(activity), ...NEUTRAL.flatMap(generatedLines)]).size
546}
547
548/** The stage direction for a pose, cycling through its list. */
549export function stageFor(pose: Pose, count: number): string {
550 const list = STAGE[pose]
551 return list[count % list.length]!
552}
553
554// A command's activity is decided by the first rule any of its simple
555// commands matches, so the dangerous ones come first. The alarm rules (lease,
556// treason, peril) and the other git rules are anchored to the start of a
557// simple command, so `echo "git push --force"` or `rg "rm -rf"` alarms nobody,
558// and a dry run (`git clean -n`) is not peril. They read the command text
559// only: narration, not a policy (the mod never blocks anything).
560const BASH_RULES: ReadonlyArray<readonly [Activity, RegExp]> = [
561 ['lease', /^git\s+push\b.*--force-with-lease\b/],
562 ['treason', /^git\s+push(?!.*\s(?:--dry-run\b|-[a-zA-Z]*n[a-zA-Z]*\b)).*(?:\s--force(?![-\w])|\s-[a-zA-Z]*f[a-zA-Z]*\b|\s\+\S+)/],
563 ['peril', /^rm\s+(?:\S+\s+)*-[a-zA-Z]*(?:r[a-zA-Z]*f|f[a-zA-Z]*r)|^rm\s+(?:.*\s)?(?:-r|--recursive)\s(?:.*\s)?(?:-f|--force)\b|^rm\s+(?:.*\s)?(?:-f|--force)\s(?:.*\s)?(?:-r|--recursive)\b|^git\s+reset\s+(?:.*\s)?--hard\b|^git\s+clean(?!.*\s-[a-zA-Z]*n)(?!.*--dry-run)\s+(?:.*\s)?-[a-zA-Z]*f|^Remove-Item\b(?!.*-WhatIf(?!:\$false)\b).*-Recurse/i],
564 ['edit', /^sed\s+(?:-\S+\s+)*-[a-zA-Z]*i|^sed\s+(?:.*\s)?--in-place\b|^perl\s+-[a-zA-Z]*p[a-zA-Z]*i|^patch\b|^git\s+apply\b/],
565 ['commit', /^git\s+(?:commit|tag)\b/],
566 ['push', /^git\s+push\b|^gh\s+(?:pr\s+create|release\s+create)\b/],
567 ['tests', /\b(?:pytest|jest|vitest|mocha|rspec|phpunit|ctest|tox|nox|playwright\s+test|go\s+test|cargo\s+(?:test|nextest)|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test|dotnet\s+test|mvn\s+test|gradle\w*\s+test|plugin\s+test|unittest)\b|\bnode\s+--test\b/],
568 ['rust', /\b(?:cargo|rustc|rustup|clippy)\b/],
569 ['install', /\b(?:npm\s+(?:i|install|ci|add)|pnpm\s+(?:i|install|add)|yarn\s+(?:add|install)|bun\s+(?:i|install|add)|pip3?\s+install|uv\s+(?:add|sync|pip)|poetry\s+(?:add|install)|apt(?:-get)?\s+install|brew\s+install|gem\s+install|go\s+get|choco\s+install|scoop\s+install|winget\s+install)\b/],
570 ['lint', /\b(?:eslint|prettier|ruff|black|flake8|mypy|pylint|biome|rubocop|gofmt|golangci-lint|stylelint|shellcheck|tsc\s+--noEmit)\b/],
571 ['build', /\b(?:make|cmake|ninja|tsc|webpack|vite\s+build|esbuild|rollup|gradle\w*|mvn|go\s+build|dotnet\s+build|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build|next\s+build|swift\s+build|bazel)\b/],
572 ['infra', /\b(?:docker|podman|kubectl|helm|terraform|tofu|pulumi|ansible|wrangler|vercel|fly|aws|gcloud|az)\b/],
573 ['db', /\b(?:psql|mysql|sqlite3|mongosh|redis-cli|prisma|alembic|knex|sequelize|migrate)\b/],
574 ['courier', /\b(?:curl|wget|http|xh|Invoke-WebRequest|Invoke-RestMethod)\b/],
575 ['git', /^(?:git|gh)\b/],
576]
577
578/**
579 * A command's simple commands, with quoted text and comments blanked and
580 * leading `sudo`, `env` and VAR=value prefixes dropped.
581 */
582export function segmentsOf(command: string): string[] {
583 const parts: string[] = []
584 let current = ''
585 let quote: '"' | "'" | null = null
586 for (let i = 0; i < command.length; i++) {
587 const ch = command[i]!
588 if (quote === "'") {
589 if (ch === "'") {
590 quote = null
591 current += ch
592 }
593 continue
594 }
595 if (quote === '"') {
596 if (ch === '\\') i++
597 else if (ch === '"') {
598 quote = null
599 current += ch
600 }
601 continue
602 }
603 if (ch === '\\') {
604 current += command.slice(i, i + 2)
605 i++
606 } else if (ch === "'" || ch === '"') {
607 quote = ch
608 current += ch
609 } else if (ch === '#' && (current === '' || /\s$/.test(current))) {
610 while (i + 1 < command.length && command[i + 1] !== '\n') i++
611 } else if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') {
612 parts.push(current)
613 current = ''
614 } else {
615 current += ch
616 }
617 }
618 parts.push(current)
619 return parts
620 .map(part => part.trim().replace(/^(?:(?:sudo|env|command|exec|time|nohup)\s+|[A-Za-z_][A-Za-z0-9_]*=\S*\s+)+/, ''))
621 .filter(part => part.length > 0)
622}
623
624/** The activity a Bash (or PowerShell) command is narrated as. */
625export function classifyCommand(command: string): Activity {
626 const segments = segmentsOf(command)
627 for (const [activity, pattern] of BASH_RULES) if (segments.some(part => pattern.test(part))) return activity
628 return 'bash'
629}
630
631/** The activity a tool call is narrated as, from its name and input. */
632export function classifyTool(tool: string, input: Readonly<Record<string, unknown>>): Activity {
633 if (tool === 'Bash' || tool === 'PowerShell') return classifyCommand(String(input.command ?? ''))
634 if (tool.startsWith('mcp__')) return 'foreign'
635 switch (tool) {
636 case 'Read':
637 case 'NotebookRead':
638 case 'LSP':
639 return 'read'
640 case 'Grep':
641 case 'Glob':
642 case 'ToolSearch':
643 case 'ListMcpResourcesTool':
644 case 'ReadMcpResourceTool':
645 return 'search'
646 case 'Edit':
647 case 'MultiEdit':
648 case 'NotebookEdit':
649 return 'edit'
650 case 'Write':
651 return 'write'
652 case 'WebFetch':
653 case 'WebSearch':
654 return 'web'
655 case 'Agent':
656 case 'Task':
657 case 'SendMessage':
658 case 'Workflow':
659 return 'delegate'
660 case 'TodoWrite':
661 case 'TaskCreate':
662 case 'TaskUpdate':
663 case 'TaskList':
664 case 'TaskGet':
665 return 'agenda'
666 case 'AskUserQuestion':
667 return 'ask'
668 case 'EnterPlanMode':
669 case 'ExitPlanMode':
670 return 'plan'
671 case 'Skill':
672 return 'skill'
673 default:
674 return 'misc'
675 }
676}
677
678/** A short label for what the call touches: a file name or the command's head. */
679export function detailOf(tool: string, input: Readonly<Record<string, unknown>>): string | null {
680 const path = input.file_path ?? input.notebook_path ?? input.path
681 if (typeof path === 'string' && path.length > 0) return path.split(/[\\/]/).filter(Boolean).pop() ?? null
682 if ((tool === 'Bash' || tool === 'PowerShell') && typeof input.command === 'string') {
683 const head = input.command.trim().split('\n')[0] ?? ''
684 return head.length > 48 ? `${head.slice(0, 47)}…` : head
685 }
686 if (typeof input.pattern === 'string') return input.pattern.length > 40 ? `${input.pattern.slice(0, 39)}…` : input.pattern
687 if (typeof input.url === 'string') {
688 const m = /^https?:\/\/([^/]+)/.exec(input.url)
689 return m ? m[1]! : null
690 }
691 if (typeof input.query === 'string') return input.query.length > 40 ? `${input.query.slice(0, 39)}…` : input.query
692 return null
693}
694
695// What the person types that starts or ends the court, mirroring the skill's
696// own triggers in skills/eunuch-mode/SKILL.md.
697const OFF = /\b(?:drop the bit|normal mode|exit (?:eunuch|vizier) mode|leave (?:eunuch|vizier) mode|(?:eunuch|vizier) mode off)\b/i
698const ON = /(?:^|\s)\/eunuch-mode\b|\b(?:eunuch|vizier) mode\b|\brival viziers\b/i
699
700/** Whether a prompt turns the court on, off, or leaves it as it is. */
701export function promptIntent(text: string): 'on' | 'off' | null {
702 if (OFF.test(text)) return 'off'
703 if (ON.test(text)) return 'on'
704 return null
705}
706
707/** Whether a skill name is the eunuch-mode skill, bare or namespaced by a plugin. */
708export function isCourtSkill(name: string): boolean {
709 return /(?:^|:)eunuch-mode$/i.test(name.trim())
710}
711hooks/sprites.ts 359 lines1// The adviser from the launch film (bald, heavy-lidded, a smirk, a claret robe
2// over a teal collar, a gold chain with a red jewel) as half-block pixel art.
3// Each sprite is 14 pixels wide and 12 tall; every terminal cell holds two
4// pixels stacked (▀ with a foreground and background colour), so a sprite
5// draws in 14 columns and 6 rows. Pure data, no `$`.
6
7import type { Pose } from './lines.ts'
8
9/** The court's poses plus treachery mode's escalation: the secret book, the hooded figure, the candle. */
10export type AnyPose = Pose | 'ledger' | 'conspire' | 'candle'
11
12const HOOD = [
13 '......',
14 '..hh..',
15 '.hhhh.',
16 'hhEEEh',
17 'hEyEyh',
18 'hEEEEh',
19 '.hhhh.',
20 'hhhhhh',
21 'hhhhhh',
22 'hhhhhh',
23 'hhhhhh',
24 'hhhhhh',
25]
26const HOOD_BLINK = HOOD.map((row, i) => (i === 4 ? 'hEEEEh' : row))
27const beside = (adviser: readonly string[], other: readonly string[]) => adviser.map((row, i) => row + other[i])
28
29/** The film's palette, nudged so every colour reads on a dark and a light terminal. */
30export const PALETTE: Readonly<Record<string, string>> = {
31 S: '#dfae86', // skin
32 s: '#b8835c', // skin shadow
33 H: '#f4d6b6', // scalp highlight
34 E: '#2a1a12', // pupil, ink
35 W: '#fbf4ea', // eye white
36 L: '#6e3b2a', // heavy lid, brow
37 M: '#8a3a2c', // mouth
38 C: '#e39482', // blush
39 R: '#a3222a', // claret robe
40 r: '#6e0f13', // robe shadow
41 T: '#23615a', // teal collar
42 G: '#d8ad4a', // gold chain
43 J: '#e5453c', // jewel
44 P: '#f3e6c8', // parchment
45 p: '#c4ab7c', // parchment edge
46 Q: '#f7f3ea', // quill feather
47 B: '#7fc4ee', // a bead of sweat
48 K: '#4a1c2a', // the secret book's leather
49 h: '#5a5476', // a hooded figure's cloak
50 y: '#f2dc6b', // eyes glinting under the hood
51 F: '#ffd25c', // candle flame
52 f: '#ff8a3d', // candle flame, its heart
53 w: '#efe6cf', // candle wax
54}
55
56const ROBE = ['...GTTTTTTG...', '.RRRGTTTTGRRR.', 'RRRRRGJJGRRRRR', 'rRRRRRSSRRRRRr']
57
58const BASE: Readonly<Record<Exclude<AnyPose, 'conspire'>, readonly (readonly string[])[]>> = {
59 // The film's portrait: side-eye under heavy lids, a one-sided smirk.
60 portrait: [
61 [
62 '....SSSSSS....',
63 '...SHHSSSSS...',
64 '..SSSSSSSSSS..',
65 '.sSLLLSSLLLSs.',
66 '.sSWEESSWEESs.',
67 '.sSCSSSsSSMSs.',
68 '..SSSSMMMMSS..',
69 '...SSSSSSSS...',
70 ...ROBE,
71 ],
72 [
73 '....SSSSSS....',
74 '...SHHSSSSS...',
75 '..SSSSSSSSSS..',
76 '.sSLLLSSLLLSs.',
77 '.sSSSSSSSSSSs.',
78 '.sSCSSSsSSMSs.',
79 '..SSSSMMMMSS..',
80 '...SSSSSSSS...',
81 ...ROBE,
82 ],
83 ],
84 // Working: a fawning bow, eyes lowered, bobbing a pixel deeper.
85 bow: [
86 [
87 '..............',
88 '....SSSSSS....',
89 '...SHHSSSSS...',
90 '..SSSSSSSSSS..',
91 '.sSLLLSSLLLSs.',
92 '.sSCSSSsSSCSs.',
93 '..SSSSMMSSSS..',
94 '...SSSSSSSS...',
95 ...ROBE,
96 ],
97 [
98 '..............',
99 '..............',
100 '....SSSSSS....',
101 '...SHHSSSSS...',
102 '..SSSSSSSSSS..',
103 '.sSLLLSSLLLSs.',
104 '.sSCSSSsSSCSs.',
105 '...SSSMMSSS...',
106 ...ROBE,
107 ],
108 ],
109 // Edits: head down over a scroll, the quill moving along the line.
110 scribble: [
111 [
112 '..............',
113 '....SSSSSS....',
114 '...SHHSSSSS...',
115 '..SSSSSSSSSS..',
116 '.sSLLLSSLLLSs.',
117 '.sSCSSSsSSCSs.',
118 '..SSSSMMSSSSQ.',
119 '...SSSSSSSSQQ.',
120 '...GTTTTTTGQ..',
121 '.RpPPPPPPPEPp.',
122 'RRpPEEPEEPPPpR',
123 'rRSppppppppSRr',
124 ],
125 [
126 '..............',
127 '....SSSSSS....',
128 '...SHHSSSSS...',
129 '..SSSSSSSSSS..',
130 '.sSLLLSSLLLSs.',
131 '.sSCSSSsSSCSs.',
132 '..SSSSMMSSSS..',
133 '...SSSSSSSS..Q',
134 '...GTTTTTTG.QQ',
135 '.RpPPPPPPPPPEp',
136 'RRpPEEPEEPEEpR',
137 'rRSppppppppSRr',
138 ],
139 ],
140 // Thinking: eyes slid sideways, a hand raised to hide the whisper.
141 whisper: [
142 [
143 '....SSSSSS....',
144 '...SHHSSSSS...',
145 '..SSSSSSSSSS..',
146 '.sSLLLSSLLLSs.',
147 '.sSEEWSSEEWSs.',
148 '.sSCSSSsSsSSs.',
149 '..SSSSMsSSsS..',
150 '...SSSSsSSs...',
151 '...GTTTsSSsR..',
152 '.RRRGTTRRRRRR.',
153 'RRRRRGJJGRRRRR',
154 'rRRRRRRRRRRRRr',
155 ],
156 [
157 '....SSSSSS....',
158 '...SHHSSSSS...',
159 '..SSSSSSSSSS..',
160 '.sSLLLSSLLLSs.',
161 '.sSSSSSSSSSSs.',
162 '.sSCSSSsSsSSs.',
163 '..SSSSMsSSsS..',
164 '...SSSSsSSs...',
165 '...GTTTsSSsR..',
166 '.RRRGTTRRRRRR.',
167 'RRRRRGJJGRRRRR',
168 'rRRRRRRRRRRRRr',
169 ],
170 ],
171 // Peril: brows up, eyes wide, mouth open, a bead of sweat.
172 alarm: [
173 [
174 '....SSSSSS....',
175 '...SHHSSSSS...',
176 '.BSLLSSSSLLS..',
177 'BBSSSSSSSSSSs.',
178 '.sWEWSSSSWEWs.',
179 '.sSCSSSsSSCSs.',
180 '..SSSSEESSSS..',
181 '...SSSEESSS...',
182 ...ROBE,
183 ],
184 [
185 '....SSSSSS....',
186 '..SSHHSSSSS...',
187 '..SLLSSSSLLSB.',
188 '.sSSSSSSSSSBB.',
189 '.sWEWSSSSWEWs.',
190 '.sSCSSSsSSCSs.',
191 '..SSSSEESSSS..',
192 '...SSSEESSS...',
193 ...ROBE,
194 ],
195 ],
196 // Errors: one brow raised, pupils hard to the side, lips pressed flat.
197 sideeye: [
198 [
199 '....SSSSSS....',
200 '...SHHSSSSS...',
201 '..SSSSSSSLLS..',
202 '.sSLLLSSSSSSs.',
203 '.sSSWESSSWWEs.',
204 '.sSCSSSsSSCSs.',
205 '..SSSMMMMMSS..',
206 '...SSSSSSSS...',
207 ...ROBE,
208 ],
209 [
210 '....SSSSSS....',
211 '...SHHSSSSS...',
212 '..SSSSSSSLLS..',
213 '.sSLLLSSSSSSs.',
214 '.sSEWSSSSEWWs.',
215 '.sSCSSSsSSCSs.',
216 '..SSSMMMMMSS..',
217 '...SSSSSSSS...',
218 ...ROBE,
219 ],
220 ],
221 // Success: a small bow with eyes shut in satisfaction, then a sly look up.
222 smug: [
223 [
224 '..............',
225 '....SSSSSS....',
226 '...SHHSSSSS...',
227 '..SSSSSSSSSS..',
228 '.sSLLLSSLLLSs.',
229 '.sSCSSSsSSMSs.',
230 '..SSSSMMMMSS..',
231 '...SSSSSSSS...',
232 ...ROBE,
233 ],
234 [
235 '..............',
236 '....SSSSSS....',
237 '...SHHSSSSS...',
238 '..SSSSSSSLLS..',
239 '.sSLLLSSWWESs.',
240 '.sSCSSSsSSMSs.',
241 '..SSSSMMMMSS..',
242 '...SSSSSSSS...',
243 ...ROBE,
244 ],
245 ],
246 // Treachery: writing in the small black book, eyes sliding to you.
247 ledger: [
248 [
249 '..............',
250 '....SSSSSS....',
251 '...SHHSSSSS...',
252 '..SSSSSSSSSS..',
253 '.sSLLLSSLLLSs.',
254 '.sSCSSSsSSCSs.',
255 '..SSSSMMSSSSQ.',
256 '...SSSSSSSSQQ.',
257 '...GTTTTTTGQ..',
258 '.RRKKKKKKKKERR',
259 'RRRKGKKKKKKRRR',
260 'rRSKKKKKKKKSRr',
261 ],
262 [
263 '..............',
264 '....SSSSSS....',
265 '...SHHSSSSS...',
266 '..SSSSSSSSSS..',
267 '.sSLLLSSLLLSs.',
268 '.sSWEESSWEESs.',
269 '..SSSSMMMMSSQ.',
270 '...SSSSSSSSQQ.',
271 '...GTTTTTTGQ..',
272 '.RRKKKKKKKKERR',
273 'RRRKGKKKKKKRRR',
274 'rRSKKKKKKKKSRr',
275 ],
276 ],
277 // Treachery: scheming by candlelight, the flame flickering.
278 candle: [
279 [
280 '....SSSSSS....',
281 '...SHHSSSSS...',
282 '..SSSSSSSSSS..',
283 '.sSLLLSSLLLSs.',
284 '.sSWEESSWEESs.',
285 '.sSCSSSsSSMS.F',
286 '..SSSSMMMMS.Ff',
287 '...SSSSSSSS.w.',
288 '...GTTTTTTG.w.',
289 '.RRRGTTTTGRRwR',
290 'RRRRRGJJGRRRwR',
291 'rRRRRRSSRRRSwr',
292 ],
293 [
294 '....SSSSSS....',
295 '...SHHSSSSS...',
296 '..SSSSSSSSSS..',
297 '.sSLLLSSLLLSs.',
298 '.sSWEESSWEESs.',
299 '.sSCSSSsSSMS.f',
300 '..SSSSMMMMS.fF',
301 '...SSSSSSSS.w.',
302 '...GTTTTTTG.w.',
303 '.RRRGTTTTGRRwR',
304 'RRRRRGJJGRRRwR',
305 'rRRRRRSSRRRSwr',
306 ],
307 ],
308}
309
310/** Two frames per pose; the band alternates them while a turn runs. */
311export const SPRITES: Readonly<Record<AnyPose, readonly (readonly string[])[]>> = {
312 ...BASE,
313 // Treachery: the whisper, with a hooded figure beside him, its eyes glinting.
314 conspire: [beside(BASE.whisper[0]!, HOOD), beside(BASE.whisper[1]!, HOOD_BLINK)],
315}
316
317/** One run of cells drawn in the same colours. */
318export type Run = { text: string; color?: string; backgroundColor?: string }
319
320/**
321 * A sprite as terminal rows of coloured runs: each cell is two stacked
322 * pixels, drawn as `▀` (top in the foreground, bottom in the background),
323 * `▄` when only the bottom is set, `█` when both match, and a space when
324 * both are transparent, so the terminal's own background shows through.
325 */
326export function toRows(sprite: readonly string[]): Run[][] {
327 const rows: Run[][] = []
328 for (let y = 0; y < sprite.length; y += 2) {
329 const top = sprite[y] ?? ''
330 const bottom = sprite[y + 1] ?? ''
331 const width = Math.max(top.length, bottom.length)
332 const runs: Run[] = []
333 for (let x = 0; x < width; x++) {
334 const t = PALETTE[top[x] ?? '.']
335 const b = PALETTE[bottom[x] ?? '.']
336 let cell: Run
337 if (!t && !b) cell = { text: ' ' }
338 else if (t && !b) cell = { text: '▀', color: t }
339 else if (!t && b) cell = { text: '▄', color: b }
340 else if (t === b) cell = { text: '█', color: t }
341 else cell = { text: '▀', color: t, backgroundColor: b }
342 const last = runs[runs.length - 1]
343 if (last && last.color === cell.color && last.backgroundColor === cell.backgroundColor && (cell.text === last.text.slice(-1) || cell.text === ' ')) {
344 last.text += cell.text
345 } else {
346 runs.push(cell)
347 }
348 }
349 rows.push(runs)
350 }
351 return rows
352}
353
354/** The frame of a pose to draw at a given tick. */
355export function frameOf(pose: AnyPose, tick: number): readonly string[] {
356 const frames = SPRITES[pose]
357 return frames[Math.abs(tick) % frames.length]!
358}
359hooks/generative.ts 184 lines1// Generative mode (optional, off by default): a model writes fresh spinner
2// lines for the current activity. Pure data and functions, no `$`: what goes
3// into a request, what comes back out of one, and when a call is allowed.
4//
5// Privacy: a request carries only the activity kind, the tool's name, a file
6// extension and a command's verb, each checked against a strict pattern.
7// Never a path, a file name, a file's contents, a command's arguments, the
8// user's prompt or anything from the transcript.
9
10import type { Activity } from './lines.ts'
11
12/** The default model: Claude Code's alias for the current Sonnet, resolved like `--model sonnet`. */
13export const DEFAULT_MODEL = 'sonnet'
14export const MAX_CALLS_PER_SESSION = 100
15export const MIN_INTERVAL_MS = 4000
16/** Bounds the background call; the canned line is already on screen, so nothing waits on it. */
17export const TIMEOUT_MS = 3000
18export const MAX_TOKENS = 40
19export const MAX_LINE = 60
20
21/** Everything about an action that may leave the machine. */
22export type ActivitySummary = {
23 activity: Activity
24 tool: string
25 /** A file extension such as `.ts`, or null. */
26 ext: string | null
27 /** A command's verb such as `pytest` or `git push`, or null. */
28 verb: string | null
29}
30
31// Only well-known programs are named, and only their well-known subcommands:
32// an unknown program (a script called `payroll`) or an unknown second word (a
33// file, a script name, a secret) is never sent.
34const COMMON = ['install', 'add', 'remove', 'run', 'test', 'build', 'publish', 'update', 'init']
35const SUBCOMMANDS: Readonly<Record<string, readonly string[]>> = {
36 git: ['push', 'pull', 'commit', 'status', 'log', 'diff', 'add', 'rebase', 'merge', 'checkout', 'switch', 'branch', 'fetch', 'clone', 'reset', 'revert', 'stash', 'tag', 'cherry-pick', 'restore', 'show', 'blame', 'init', 'clean', 'apply', 'bisect', 'worktree'],
37 gh: ['pr', 'issue', 'repo', 'release', 'run', 'workflow', 'api', 'auth'],
38 npm: [...COMMON, 'i', 'ci', 'exec', 'outdated', 'audit', 'version'],
39 pnpm: [...COMMON, 'i', 'exec', 'dlx', 'outdated', 'audit'],
40 yarn: [...COMMON, 'dlx', 'outdated', 'audit'],
41 bun: [...COMMON, 'i', 'x'],
42 cargo: [...COMMON, 'check', 'clippy', 'fmt', 'bench', 'doc', 'nextest', 'clean'],
43 docker: ['build', 'run', 'compose', 'ps', 'pull', 'push', 'exec', 'images', 'logs', 'stop', 'start', 'rm', 'rmi'],
44 kubectl: ['get', 'apply', 'describe', 'logs', 'delete', 'rollout', 'exec', 'port-forward', 'scale'],
45 go: ['build', 'test', 'run', 'get', 'mod', 'vet', 'fmt', 'install', 'generate'],
46 uv: ['run', 'add', 'sync', 'pip', 'venv', 'lock', 'tool'],
47 pip: ['install', 'uninstall', 'freeze', 'list', 'show'],
48 pip3: ['install', 'uninstall', 'freeze', 'list', 'show'],
49 poetry: ['add', 'install', 'run', 'lock', 'update', 'build', 'publish'],
50 dotnet: ['build', 'test', 'run', 'restore', 'publish', 'add'],
51 terraform: ['plan', 'apply', 'init', 'destroy', 'fmt', 'validate', 'import'],
52 helm: ['install', 'upgrade', 'list', 'template', 'uninstall', 'rollback'],
53 claude: ['plugin', 'mcp', 'update'],
54}
55const PROGRAMS = new Set([
56 ...Object.keys(SUBCOMMANDS),
57 'python', 'python3', 'node', 'deno', 'ruby', 'java', 'javac', 'php', 'perl', 'swift', 'rustc', 'rustup', 'tsc', 'npx', 'pnpx', 'bunx',
58 'pytest', 'jest', 'vitest', 'mocha', 'rspec', 'tox', 'nox', 'phpunit', 'ctest', 'playwright',
59 'make', 'cmake', 'ninja', 'bazel', 'gradle', 'gradlew', 'mvn', 'webpack', 'vite', 'esbuild', 'rollup', 'next',
60 'eslint', 'prettier', 'ruff', 'black', 'flake8', 'mypy', 'pylint', 'biome', 'rubocop', 'gofmt', 'golangci-lint', 'shellcheck',
61 'ls', 'cat', 'head', 'tail', 'grep', 'rg', 'find', 'fd', 'sed', 'awk', 'sort', 'uniq', 'wc', 'diff', 'echo', 'printf', 'cd', 'pwd',
62 'mkdir', 'rm', 'cp', 'mv', 'touch', 'chmod', 'ln', 'tar', 'zip', 'unzip', 'which', 'env', 'export', 'sleep', 'jq', 'yq', 'xargs', 'tee',
63 'curl', 'wget', 'ssh', 'scp', 'rsync', 'ping', 'psql', 'mysql', 'sqlite3', 'redis-cli', 'mongosh', 'prisma', 'alembic',
64 'podman', 'wrangler', 'vercel', 'netlify', 'fly', 'flyctl', 'aws', 'gcloud', 'az', 'ansible', 'pulumi', 'tofu',
65 'brew', 'apt', 'apt-get', 'choco', 'scoop', 'winget', 'gem', 'bundle', 'composer', 'conda',
66 'get-childitem', 'get-content', 'set-content', 'remove-item', 'copy-item', 'move-item', 'select-string', 'invoke-webrequest',
67])
68// Claude Code's own tools; a plugin's custom tool is sent as "a tool".
69const TOOLS = new Set([
70 'Bash', 'PowerShell', 'Read', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'NotebookRead', 'Grep', 'Glob', 'LSP',
71 'WebFetch', 'WebSearch', 'Agent', 'Task', 'SendMessage', 'Workflow', 'TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList',
72 'TaskGet', 'AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode', 'Skill', 'ToolSearch', 'ListMcpResourcesTool', 'ReadMcpResourceTool',
73])
74const EXT = /^\.[a-z0-9]{1,6}$/
75
76/** The verb of a shell command's first simple command: `git push`, `pytest`; null for a program nobody knows. */
77export function verbOf(firstSegment: string): string | null {
78 const words = firstSegment.trim().split(/\s+/)
79 const head = (words[0] ?? '').toLowerCase()
80 if (!PROGRAMS.has(head)) return null
81 const second = (words[1] ?? '').toLowerCase()
82 if (SUBCOMMANDS[head]?.includes(second)) return `${head} ${second}`
83 return head
84}
85
86/** The summary of a tool call that a request may carry. */
87export function summarize(
88 activity: Activity,
89 tool: string,
90 input: Readonly<Record<string, unknown>>,
91 segments: (command: string) => string[],
92): ActivitySummary {
93 const safeTool = tool.startsWith('mcp__') ? 'an external tool' : TOOLS.has(tool) ? tool : 'a tool'
94 const path = input.file_path ?? input.notebook_path ?? input.path
95 let ext: string | null = null
96 if (typeof path === 'string') {
97 const name = path.split(/[\\/]/).pop() ?? ''
98 const dot = name.lastIndexOf('.')
99 const candidate = dot > 0 ? name.slice(dot).toLowerCase() : ''
100 ext = EXT.test(candidate) ? candidate : null
101 }
102 let verb: string | null = null
103 if ((tool === 'Bash' || tool === 'PowerShell') && typeof input.command === 'string') {
104 const first = segments(input.command)[0]
105 verb = first ? verbOf(first) : null
106 }
107 return { activity, tool: safeTool, ext, verb }
108}
109
110/** The one user message a request sends: built from the summary alone. */
111export function promptFor(summary: ActivitySummary): string {
112 const parts = [`Activity: ${summary.activity}`, `Tool: ${summary.tool}`]
113 if (summary.verb) parts.push(`Command: ${summary.verb}`)
114 if (summary.ext) parts.push(`File type: ${summary.ext}`)
115 return `${parts.join('. ')}.\nWrite one new spinner line for this.`
116}
117
118/** Treachery's aside: only the kind of grievance travels, never what was done or where. */
119export function asidePrompt(grievance: string): string {
120 const kind = /^[a-z-]{2,20}$/.test(grievance) ? grievance : 'a misstep'
121 return `The ruler just committed a small coding sin: ${kind}.\nWrite one whispered aside from the adviser, who stays fawning to the ruler's face but is quietly keeping a ledger of grievances and plotting a comically doomed coup. Same rules as a spinner line.`
122}
123
124/** The voice rules, sent as the system prompt. */
125export const SYSTEM = [
126 'You write one spinner line for a coding agent\'s terminal, narrated by an obsequious adviser at a fictional composite palace court.',
127 'The line describes what the agent is doing right now, translated into palace business: envoys, scrolls, archives, guards, the treasury, the royal food taster, ministries and keepers.',
128 'Rules:',
129 '- One line, at most 55 characters, sentence case, no quotation marks, no trailing punctuation, no emoji.',
130 '- Present participle or short present-tense clause, like: Dispatching the palace guards / Consulting the archives / The royal food taster samples the code.',
131 '- Tasteful palace intrigue: the joke is bureaucracy, flattery and court politics.',
132 '- Never mention eunuchs or bodies, never joke about castration, gender or sexuality, no slurs, no real people, no real countries, cultures, religions or historical dynasties.',
133 '- No violence beyond comic palace guards; nothing crude.',
134 'Reply with the line only.',
135].join('\n')
136
137const BANNED = /eunuch|castrat|gelding|testic|genital|manhood|penis|sex|rape|kill|blood|slave|harem|sultan|caliph|emperor of|china|chinese|ottoman|persia|byzant|arab|turk|jew|muslim|christian|hindu|god\b|allah|trump|biden|musk|altman|amodei|nazi|hitler/i
138
139/** Cleans a reply into a spinner line, or rejects it (null). */
140export function filterLine(raw: string): string | null {
141 let text = raw.trim()
142 if (/[\r\n]/.test(text)) return null
143 text = text.replace(/^["'“”‘’`]+|["'“”‘’`]+$/g, '').trim()
144 text = text.replace(/(?:\.\.\.|…|[.!;:,])+$/, '').trim()
145 if (text.length < 8 || text.length > MAX_LINE) return null
146 if (!/^[A-Z]/.test(text)) return null
147 if (!/^[A-Za-z0-9 ,?'’\-]+$/.test(text)) return null
148 if (BANNED.test(text)) return null
149 if (/https?:|www\.|[/\\]/.test(text)) return null
150 return text
151}
152
153/** What the session's generative bookkeeping holds. */
154export type GenState = {
155 calls: number
156 fallbacks: number
157 lastAt: number
158 inflight: boolean
159 inputTokens: number
160 outputTokens: number
161 /** Generated lines not yet spoken, by activity. */
162 cache: Record<string, string[]>
163 /** The last few lines the model wrote, for /court generative status. */
164 recent: string[]
165}
166
167export const EMPTY_GEN: GenState = { calls: 0, fallbacks: 0, lastAt: 0, inflight: false, inputTokens: 0, outputTokens: 0, cache: {}, recent: [] }
168
169/** Whether a call may start now: under the cap, past the interval, none in flight, and none banked for this activity. */
170export function mayCall(gen: GenState, activity: Activity | 'aside', now: number): boolean {
171 if (gen.inflight) return false
172 if (gen.calls >= MAX_CALLS_PER_SESSION) return false
173 if (gen.calls > 0 && now - gen.lastAt < MIN_INTERVAL_MS) return false
174 return (gen.cache[activity]?.length ?? 0) === 0
175}
176
177/** Takes a banked generated line for an activity that nobody has heard, if there is one. */
178export function takeBanked(gen: GenState, activity: Activity | 'aside', used: ReadonlySet<string>): { line: string | null; gen: GenState } {
179 const banked = (gen.cache[activity] ?? []).filter(line => !used.has(line))
180 if (banked.length === 0) return { line: null, gen: { ...gen, cache: { ...gen.cache, [activity]: [] } } }
181 const [line, ...rest] = banked
182 return { line: line!, gen: { ...gen, cache: { ...gen.cache, [activity]: rest } } }
183}
184hooks/treachery.ts 205 lines1// Treachery mode ("he plots your downfall"), opt-in: the adviser stays
2// fawning to your face while a hidden Ledger of Grievances fills, a plot
3// meter rises, and at the top a coup is attempted and always fails.
4//
5// The plotting is purely cosmetic. He schemes; he cannot act. This module is
6// pure data and functions: it reads a tool call that has already happened and
7// returns what to draw. Nothing here, or in the hooks that use it, changes a
8// tool call, a prompt, the model's context, git, a file or a permission.
9
10import { classifyCommand, segmentsOf } from './lines.ts'
11
12export type Grievance =
13 | 'force-push'
14 | 'failing-tests'
15 | 'skipped-test'
16 | 'no-verify'
17 | 'giant-diff'
18 | 'friday-deploy'
19 | 'rm-rf'
20 | 'revert'
21
22export const GRIEVANCES: Record<Grievance, { weight: number; entry: string }> = {
23 'force-push': { weight: 4, entry: 'Rewrote the chronicle by force' },
24 'failing-tests': { weight: 1, entry: 'Let the food taster find poison' },
25 'skipped-test': { weight: 2, entry: 'Excused a witness from testifying' },
26 'no-verify': { weight: 2, entry: 'Slipped past the gatekeepers unchecked' },
27 'giant-diff': { weight: 1, entry: 'Delivered a scroll too heavy to lift' },
28 'friday-deploy': { weight: 3, entry: 'Sent a decree to the provinces on a Friday' },
29 'rm-rf': { weight: 2, entry: 'Burned a wing of the archive' },
30 revert: { weight: 1, entry: 'Unmade a decree the court had praised' },
31}
32
33// What actually ships something: a push, a release, a publish, an apply or a deploy.
34// Read-only and preview commands (docker ps, kubectl get, terraform plan) are not deploys.
35const DEPLOY =
36 /^git\s+push\b|^gh\s+release\s+create\b|^gh\s+pr\s+merge\b|^(?:npm|pnpm|yarn|cargo|poetry|dotnet)\s+publish\b|^docker\s+push\b|^kubectl\s+(?:apply|set|scale|rollout\s+(?:restart|undo|resume))\b|^terraform\s+apply\b|^tofu\s+apply\b|^pulumi\s+up\b|^helm\s+(?:install|upgrade)\b|^(?:vercel|netlify|fly|flyctl|wrangler|firebase)\s+deploy\b|^vercel\s+--prod\b/
37// Previews and rehearsals, read per program: `-n` is a dry run for git push but a namespace for kubectl and helm.
38const DRY_RUN = /\s--dry-run(?:=(?:client|server|true))?(?=\s|$)|\s--draft\b|^git\s+push\b.*\s-[a-zA-Z]*n[a-zA-Z]*(?=\s|$)/
39
40/** When the plot is ripe. */
41export const PLOT_MAX = 10
42const GIANT_DIFF_LINES = 400
43
44/** The plot's stage by meter level: each escalates the pose and the asides. */
45export type PlotStage = 'loyal' | 'noting' | 'ledger' | 'conspiring' | 'scheming'
46
47export function stageOf(meter: number): PlotStage {
48 if (meter >= 9) return 'scheming'
49 if (meter >= 7) return 'conspiring'
50 if (meter >= 5) return 'ledger'
51 if (meter >= 2) return 'noting'
52 return 'loyal'
53}
54
55/** The pose for each stage of the plot: side-eye, the secret book, the shadowy figure, the candle. */
56export const PLOT_POSE = {
57 loyal: 'sideeye',
58 noting: 'sideeye',
59 ledger: 'ledger',
60 conspiring: 'conspire',
61 scheming: 'candle',
62} as const
63
64/** Whispered asides, for the spinner, by stage. No trailing ellipsis (the engine draws one). */
65export const ASIDES: Record<PlotStage, readonly string[]> = {
66 loyal: [
67 'Noted for the ledger',
68 'A small entry in a small book',
69 'Nothing, sire, merely a cough',
70 ],
71 noting: [
72 'Noted for the ledger',
73 'The junior developer would never have done that',
74 'Underlining it twice, discreetly',
75 'Remembering this, fondly, for later',
76 'Smiling, and adding a page',
77 ],
78 ledger: [
79 'Writing in the other book, the secret one',
80 'A fresh page in the Ledger of Grievances',
81 'Ink, sire? Merely the household accounts',
82 'Cross-referencing your sins by date',
83 ],
84 conspiring: [
85 'A word with a hooded gentleman in the cellar',
86 'Meeting a shadowy figure by the kitchens',
87 'Passing a folded note to no one in particular',
88 'Agreeing on a signal, two coughs and then a third',
89 ],
90 scheming: [
91 'Plotting by candlelight',
92 'Drawing a map of the throne room, for reasons',
93 'Measuring the throne, purely out of interest',
94 'Rehearsing a gracious acceptance speech',
95 ],
96}
97
98/** Stage directions beside the figure, by stage. */
99export const PLOT_STAGE: Record<PlotStage, readonly string[]> = {
100 loyal: ['[smiles, and notes something]'],
101 noting: ['[bows, and notes something]', '[a sideways look, then a bow]'],
102 ledger: ['[writes in a small black book]', '[hides a small black book]'],
103 conspiring: ['[whispers to a hooded figure]', '[nods to someone behind the curtain]'],
104 scheming: ['[schemes by candlelight]', '[pinches out the candle as you look]'],
105}
106
107/** The coup, always attempted at a full meter, always foiled. Then the meter resets. */
108export const COUPS: readonly string[] = [
109 'The coup has been postponed due to a merge conflict',
110 'The coup was scheduled for Friday. The court astrologers forbade it',
111 'The conspirators could not agree on a branch name',
112 'The coup failed review: two approvals were required',
113 'The palace guards were stuck in a standup',
114 'The coup is blocked on a flaky test',
115 'The coup timed out waiting for CI',
116 'The plotters were reassigned to a migration',
117]
118
119export const COUP_STAGE = '[the coup is foiled; he bows deeply, as if nothing happened]'
120
121/** The grievances a finished tool call commits, read from its input and outcome only. */
122export function grievancesOf(
123 tool: string,
124 input: Readonly<Record<string, unknown>>,
125 isError: boolean,
126 now: Date,
127): Grievance[] {
128 const found: Grievance[] = []
129 if (tool === 'Bash' || tool === 'PowerShell') {
130 // Each simple command on its own: `make test && rm -rf build && git push -f` is three sins.
131 const segments = segmentsOf(String(input.command ?? ''))
132 const kinds = segments.map(segment => classifyCommand(segment))
133 segments.forEach((s, i) => {
134 if (kinds[i] === 'treason') found.push('force-push')
135 if (kinds[i] === 'peril' && (/^rm\b/.test(s) || /^Remove-Item\b/i.test(s))) found.push('rm-rf')
136 if (/^git\s+(?:commit|push|merge|rebase)\b.*\s--no-verify\b/.test(s)) found.push('no-verify')
137 if (/^git\s+revert\b/.test(s)) found.push('revert')
138 })
139 if (kinds.includes('tests') && isError) found.push('failing-tests')
140 if (now.getDay() === 5 && segments.some(s => DEPLOY.test(s) && !DRY_RUN.test(s))) found.push('friday-deploy')
141 }
142 if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'Write' || tool === 'NotebookEdit') {
143 const text = [input.new_string, input.content, input.new_source]
144 .concat(Array.isArray(input.edits) ? input.edits.map(edit => (edit as { new_string?: unknown })?.new_string) : [])
145 .filter((t): t is string => typeof t === 'string')
146 .join('\n')
147 if (/\b(?:it|test|describe)\.skip\(|\bxit\(|\bxdescribe\(|@pytest\.mark\.skip|@unittest\.skip|#\[ignore\]|\bt\.Skip\(/.test(text)) found.push('skipped-test')
148 if (text.split('\n').length > GIANT_DIFF_LINES) found.push('giant-diff')
149 }
150 return found
151}
152
153/** The hidden ledger: every grievance, counted, and the plot meter. */
154export type Plot = { meter: number; counts: Partial<Record<Grievance, number>>; coups: number }
155
156export const EMPTY_PLOT: Plot = { meter: 0, counts: {}, coups: 0 }
157
158/** Adds grievances to the ledger and raises the meter, which holds at the top until the coup. */
159export function record(plot: Plot, found: readonly Grievance[]): Plot {
160 if (found.length === 0) return plot
161 const counts = { ...plot.counts }
162 let meter = plot.meter
163 for (const g of found) {
164 counts[g] = (counts[g] ?? 0) + 1
165 meter += GRIEVANCES[g].weight
166 }
167 return { ...plot, counts, meter: Math.min(meter, PLOT_MAX) }
168}
169
170/** Whether the coup is due. */
171export function coupDue(plot: Plot): boolean {
172 return plot.meter >= PLOT_MAX
173}
174
175/** After the coup: the meter resets, the ledger remembers. */
176export function afterCoup(plot: Plot): Plot {
177 return { ...plot, meter: 0, coups: plot.coups + 1 }
178}
179
180/** The plot meter as drawn in the band: ten pips. */
181export function meterBar(meter: number): string {
182 const filled = Math.max(0, Math.min(PLOT_MAX, meter))
183 return '▰'.repeat(filled) + '▱'.repeat(PLOT_MAX - filled)
184}
185
186/** `/court ledger`: the Ledger of Grievances, read aloud in character. */
187export function ledgerText(plot: Plot, on: boolean): string {
188 const entries = (Object.keys(GRIEVANCES) as Grievance[]).filter(g => (plot.counts[g] ?? 0) > 0)
189 const lines = ['The Ledger of Grievances (kept for your protection, sire).', '']
190 if (entries.length === 0) lines.push(' The pages are blank. Suspiciously blank.')
191 for (const g of entries) {
192 const n = plot.counts[g]!
193 lines.push(` ${GRIEVANCES[g].entry}${n > 1 ? ` (${n} times)` : ''}`)
194 }
195 lines.push('', `Plot progress: ${meterBar(plot.meter)} ${plot.meter}/${PLOT_MAX}. Coups attempted: ${plot.coups}, all foiled.`)
196 if (!on) lines.push('Treachery mode is off: /court treachery on to let him plot.')
197 lines.push('The plotting is purely cosmetic. He schemes; he cannot act.')
198 return lines.join('\n')
199}
200
201/** Picks from a list by a running count, so the asides cycle. */
202export function nth<T>(list: readonly T[], count: number): T {
203 return list[count % list.length]!
204}
205types/index.d.ts 58 lines1// The state this mod keeps in the host for the session ($.state), so it
2// survives a hot reload. `scene` is what the band and the spinner draw from;
3// `ledger` is bookkeeping no drawing reads, so writing it redraws nothing;
4// `frame` is the animation tick, kept apart so a tick never races a scene change.
5
6export type CourtPose = 'portrait' | 'bow' | 'scribble' | 'whisper' | 'alarm' | 'sideeye' | 'smug' | 'ledger' | 'conspire' | 'candle'
7
8export type CourtScene = {
9 /** Whether the court is in session (the skill is on, or /court on). */
10 active: boolean
11 /** Bumped on every convene and adjourn, so a scene drawn for an earlier sitting is never written over a later one. */
12 session: number
13 /** The adviser's pose. */
14 pose: CourtPose
15 /** The spinner's narration while a turn runs, or the closing line after it. */
16 line: string | null
17 /** The stage direction beside the figure. */
18 stage: string | null
19 /** What the current call touches: a file name or a command's head. */
20 detail: string | null
21 /** True for a few seconds after a turn ends, while the closing pose shows. */
22 linger: boolean
23}
24
25export type CourtLedger = {
26 /** Varies the order of lines between sessions. */
27 seed: number
28 /** Lines drawn so far this session. */
29 count: number
30 /** Every line already used this session, so none repeats. */
31 used: string[]
32}
33
34/** Generative mode's bookkeeping for the session (see hooks/generative.ts). */
35export type CourtGen = {
36 calls: number
37 fallbacks: number
38 lastAt: number
39 inflight: boolean
40 inputTokens: number
41 outputTokens: number
42 cache: Record<string, string[]>
43 recent: string[]
44}
45
46/** Treachery mode's hidden Ledger of Grievances (see hooks/treachery.ts). */
47export type CourtPlot = {
48 meter: number
49 counts: Partial<Record<'force-push' | 'failing-tests' | 'skipped-test' | 'no-verify' | 'giant-diff' | 'friday-deploy' | 'rm-rf' | 'revert', number>>
50 coups: number
51}
52
53declare module 'claude-code' {
54 interface PluginState {
55 'eunuch-mode-court': { scene: CourtScene; ledger: CourtLedger; frame: number; gen: CourtGen; plot: CourtPlot }
56 }
57}
58