Guilda de RPG que monitora a sessão: personagens animados em pixel art, atributos de uso e XP por tarefa

English · Português
A Claude Code mod that turns your session monitor into a tiny RPG. A guild of three pixel-art characters lives in a side panel (or a band above your prompt), works while the agent works, and levels up as tasks get done. Your usage limits, context window and cost show up as game meters.
Not affiliated with Anthropic. The orange mascot is a fan-made homage to the Claude Code mascot. "Claude" and "Claude Code" are trademarks of Anthropic.
<img src="docs/art/mago_design_preview16x.png" alt="Mage" width="160"> <img src="docs/art/guerreiro_design_preview16x.png" alt="Warrior" width="160"> <img src="docs/art/explorador_design_preview16x.png" alt="Explorer" width="160"> <img src="docs/art/bug_design_preview16x.png" alt="Bug" width="160">
| Character | Does | Comes on stage when the agent… |
|---|---|---|
| Mage | plans | is in plan mode, or organises the work (TodoWrite, AskUserQuestion, plan tasks, Plan subagents) |
| Warrior | executes and fixes | changes things: Edit, Write, MultiEdit, shell commands that are not read-only, other subagents |
| Explorer (default) | researches and investigates | reads or searches: Read, Grep, Glob, WebSearch, WebFetch, read-only shell commands, Explore subagents |
The character on stage is the one most of the last three tool calls belong to (a tie goes to the latest). Before the first tool call of a turn, the prompt gives a first guess ("fix the bug" → Warrior, "plan the feature" → Mage); with nothing to go on, it is the Explorer.
| State | When | Animation |
|---|---|---|
| working | a turn is running | the active character works |
| tired | context ≥ 75% (configurable) | everyone looks worn out |
| asleep | idle for 90 s (configurable) | everyone sleeps |
| idle | otherwise | everyone waits, blinking |
<table align="center"> <tr> <td align="center"><img src="docs/art/mago_trabalho_preview.gif" width="128"><br>Mage working</td> <td align="center"><img src="docs/art/guerreiro_trabalho_preview.gif" width="128"><br>Warrior working</td> <td align="center"><img src="docs/art/explorador_trabalho_preview.gif" width="128"><br>Explorer working</td> <td align="center"><img src="docs/art/bug_parado_preview.gif" width="128"><br>Bug</td> </tr> <tr> <td align="center"><img src="docs/art/mago_parado_preview.gif" width="128"><br>Idle</td> <td align="center"><img src="docs/art/guerreiro_cansado_preview.gif" width="128"><br>Tired</td> <td align="center"><img src="docs/art/explorador_dormindo_preview.gif" width="128"><br>Asleep</td> <td align="center"><img src="docs/art/bug_derrotado_preview.gif" width="128"><br>Defeated</td> </tr> </table>
| Meter | Shows | Color |
|---|---|---|
| HP | what is left of the 7-day usage window | green above 50%, yellow 25–50%, red below 25% |
| MP | what is left of the 5-hour window, with the time until it resets | blue |
| ST (stamina) | what is left of the context window | orange |
| Gold | what the session has cost, in USD | gold |
| XP | progress to the next level of the active character | purple |
HP and MP come from your subscription's rate limits: outside a subscription (for instance with an API key) they show --. A figure the session has not reported yet keeps its last known value, so the meters never blank when you switch characters.
HP, MP and stamina warn you in a toast before a limit hits: when what is left of a meter falls below 20% and then below 10%. The text is in Portuguese and says the value and the time until the reset, for example O Mago está quase sem mana: 18%, reinicia em 1h40 (the 7-day HP uses days: reinicia em 3d 5h). The stamina has no reset; its toast reminds that /compact recovers it: O Explorador está quase sem fôlego: 18%. /compact recupera.
/guild trace log, and no toast carries a path, prompt or command.alerts option. on (default) or off. With off the guild shows no toast at all, the rest toast of the compactions included.Next to HP and MP the guild says when each window runs out at the current pace, in Portuguese like the rest of the interface: 5h esgota em ~40min, 7d esgota em ~3 dias, 5h dura até o reset (it lasts until the window resets) or 7d sem consumo (nothing is being used). It is red when the window runs out before it resets.
bug na área! warning. In a narrow panel the text loses the em (5h esgota ~40min) and, if even that does not fit, the line is left out./guild trace log or shown in a toast. The option showForecast set to off hides it all.5h esgota em ~1h30 in red and 7d dura até o reset.Every tool call that finishes without error pays XP at once to the class it belongs to, so a task that explores, plans and then executes feeds all three characters:
operation XP = 2 + min(seconds / 2, 4) + min(lines written / 20, 4) (10 at most)
Level n starts at 40 × (n − 1)² XP: level 2 at 40, level 3 at 160, level 4 at 360, level 5 at 640. XP is saved in the plugin store (guild.v1) and survives restarts. A toast announces each new level.
When a tool call fails, the bug appears under the active character's stats. A later successful call of the same kind defeats it (for shell commands, the same program and subcommand: npm test, git push; for other tools, the same tool). If no such call comes, the bug leaves on its own after 2 minutes without a new tool error; a new error restarts that count. Calls refused by a permission or a hook, and the ones you reject yourself (a declined plan, a denied permission, an interrupted question), are not failures: they raise no bug, defeat no live bug and pay no XP. In /guild trace they show as RECUSADA, not ERRO. Spotting a rejection is best effort: a rejected plan or question is always recognized, but for the other tools it relies on the refusal text of the engine, which can change between versions, and a shell command you interrupt while it runs still raises a bug.
A line at the top of the side panel (and at the top of the stats in the bottom band, when they fit) says what the active character is doing right now, for example Warrior: editando register.tsx or Explorer: pesquisando na web. The text is in Portuguese, like the rest of the interface.
lendo (Read), buscando (Grep, Glob), pesquisando na web (WebSearch, WebFetch), editando (Edit), escrevendo (Write), executando (Bash), planejando (plans and to-do lists) and chamando um aliado (subagents; what the subagent does inside is not shown).npm test, git commit), cut at 24 characters. Flags, values, NAME=value assignments, URLs and credentials are never shown, since the panel may be shared or captured; … marks what was left out. It exists only on screen and in memory: it is never saved to the store or to the /guild trace log.de prontidão, cansado or dormindo.explorador, mago, guerreiro and bug show a call in progress; cansado and dormindo show the resting state. The screenshots below were taken before this line existed and do not show it.The side panel shows two timers, just above the latest operations: sessão 1h05 (how long the session has been open) and turno 32s (how long the current turn has run). The labels are in Portuguese, like the rest of the interface, and the format is short: 32s under a minute, 4min under an hour, 1h05 after that.
/clear starts it over (the new start is picked up within 2 seconds). It reads -- while the start is unknown.-- before the first turn. An interrupted or failed turn keeps its duration too.ouro $1.50 · turno 32s), and only while a turn runs, so the compact band's 8 lines still hold./guild trace log or a toast.explorador, mago, guerreiro and bug with a session open for 1h05 and a turn running for 32s; cansado and dormindo with 1h05 and the last turn's 4min.The side panel has a line of counters above the gold split and the session and turn timers, for example combo x7 · melhor x9 · bugs 3: the combo (operations in a row that finished without error), the best combo of the session (melhor) and the bugs defeated in the session. The labels are in Portuguese, like the rest of the interface.
x1. Before that the line is just bugs 0.bugs 3 · 3 arquivos · 142 linhas · 8 comandos: the distinct files edited (Edit, Write, MultiEdit, NotebookEdit; the same file edited again counts once), the lines written (the same value that feeds the XP) and the shell commands run. Only operations that worked count, by the same rule as the XP. The three are hidden while all are zero. Of the files only a short fingerprint is kept in memory, never the path, and only the last 1000 distinct ones./compact or the automatic one) the line ends with 1 compactação, 2 compactações, and nothing while there are none. Compactions by subagents, vetoed ones and the engine's precompute are not counted. The segment is the first to go on a narrow panel: in the short form (2 compact.) and then dropped, before the damage.A guilda descansou: ST recuperada de 12% para 64%. The compaction event does not carry the new percentage, so the toast appears when the next measurement arrives, with the stamina known before the compaction and the one measured after it. It is shown only when the stamina went up. It follows the alerts option: with off it is not shown./guild trace log, no toast carries a path or a count (the rest toast only has the two percentages), and everything starts over with the session. Lifetime totals are a separate feature.combo x7 · melhor x9 · bugs 3; the bug scene has just lost its combo and shows melhor x9 · bugs 3.The side panel splits the session's cost between the three characters, between the counters and the session timer:
Mago ~$1.20 (35%)
Guerreiro ~$1.71 (50%)
Explorador ~$0.51 (15%)
It is an estimate, not accounting: that is what the ~ stands for. The cost arrives in separate readings (every 2 seconds and whenever the engine reports a measurement) and the plugin does not know which call caused which part of it.
ouro line (the whole session). When that starting cost is above zero, a dim caption, desde a abertura, sits right under the lines (it is left out when the panel is too narrow for it, and a new session, which starts at zero, has none)./clear starts the session's cost over) clears the split and starts again from the new value. A reading that is missing, NaN, negative or over a billion dollars is ignored altogether.agentId): what a subagent costs is part of the session's cost and goes to the main agent's class that ran in the interval.Mago ~$1.20 (35%), then Mago ~$1.20 35%, then Mag ~$1.20 35%, then just ~$1.20 35%; if not even that fits, the lines are left out./guild trace log or a toast, and everything starts over with the session. A history by class is a separate feature.Mago ~$1.20 (35%), Guerreiro ~$1.71 (50%) and Explorador ~$0.51 (15%).The side panel shows, right above the latest operations, how many tokens the session spent and how much the prompt cache helped:
tokens input 142k · output 18k · cache 87%
sessão input 1,2M · output 96k · cache 91%
subagentes input 30k · output 4,1k · cache 38%
The labels are in Portuguese, like the rest of the interface.
tokens is the last main-agent turn that reported usage, sessão (session) is the session's running total and subagentes is the running total of subagent turns, on a line of its own so it does not blur the main agent's (it exists only after the first subagent turn; with no main turn yet, it is the only one).input shown is everything the model read (uncached + cache read + cache written). The cache hit rate is cache read / (cache read + uncached input + cache written), per turn and accumulated (the rate of the sum, not the mean of the rates). When it cannot be computed (output only), the line says cache --.999, 12,4k, 142k, 1,2M.Guerreiro Nv.3 sonnet-5-5, without the claude- prefix and the date). If it does not fit it shows only the family (sonnet), and if not even that, it is left out.tokens input 142k · output 18k · cache 87%, then tokens in 142k · out 18k · 87%, then tokens 142k/18k 87%, then just tokens 87%; if not even the last fits, the line is left out./guild trace log or a toast, and everything starts over with the session.tokens input 142k · output 18k · cache 87%, sessão input 1,2M · output 96k · cache 91% and subagentes input 30k · output 4,1k · cache 38% (this one in red, to show the low rate), with the model sonnet-5-5 beside the name. While a scene runs, the real usage is ignored.The meters and the text lines can use one of three color schemes, independent of the characters' colors:
| Scheme | What it is |
|---|---|
padrao | the colors the monitor always had (HP green, yellow, red; MP blue; ST orange; XP purple; gold yellow; unknown gray) |
contraste | high contrast: bright colors with at least 7:1 against black (WCAG AAA) |
daltonico | does not rely on green against red: the Okabe-Ito palette, made to be told apart with protanopia, deuteranopia and tritanopia. The three HP levels also differ in lightness (pale blue, sky blue, vermillion), so HP reads without telling hues apart |
meters option sets the scheme (padrao, contraste or daltonico; default padrao). /guild meters NAME switches at once, saves the choice for the next sessions and answers Esquema dos medidores: NAME (salvo); /guild meters without a name lists the schemes with the current one marked (atual); an unknown name changes nothing and shows the valid ones. A saved choice wins over the option; with none saved the option applies. During a esquema-* demo scene the scene keeps its own scheme on screen: /guild meters still saves, and the answer says the saved scheme shows after /guild off.-- of a figure nobody reported keeps a gray that stays distinguishable in every scheme.esquema-padrao, esquema-contraste and esquema-daltonico (/guild esquema-daltonico) show the warrior at work in that scheme, whatever the saved one, and save nothing; HP goes through 72%, 38% and 15% every 3 seconds, so all three levels of HP show. /guild off goes back to the real session and to the saved scheme.daltonico scheme is checked by tests against the common kinds of color blindness (protanopia, deuteranopia, tritanopia, simulated with the Machado, Oliveira and Fernandes 2009 model): no two meters get closer than a set color distance, and the three HP levels differ in luminance. Every daltonico color also keeps at least 4.5:1 against black (WCAG AA), checked by a test; for that, mana is a lightened Okabe-Ito blue and the unknown value a light gray. The schemes assume a dark terminal background.On a wide terminal in Claude Code's fullscreen layout (/tui fullscreen, 110 columns or more), the guild opens as a panel to the left of the conversation:
3min Warrior +6 Edit);hooks/register.tsx 589 lines1import { atom, read, update } from 'claude-code'
2import type { CoreEngineInterface, Register, StateDollar } from 'claude-code'
3
4import type { ViewModel } from '../types'
5import type { TaskClass, ToolUse } from './classify'
6import { classOf, kindOf, linesWritten } from './classify'
7import { alertsOn, alertText, resetText } from './alerts'
8import { compactionToast, countsAsCompaction } from './compactions'
9import { fileOf } from './damage'
10import { targetOf } from './doing'
11import { chooseFrom, describeChoices, helpText, mergeSaved, requestOf, savedChoice, savedText } from './choices'
12import { choiceOf, demoState, SCENES, SCHEME_SCENES, sceneOf, schemeOfScene } from './demo'
13import type { History } from './forecast'
14import { EMPTY_HISTORY, recordReadings } from './forecast'
15import type { Settings } from './guild'
16import { DEFAULTS, isRejection } from './guild'
17import type { Size } from './layout'
18import { chooseLayout } from './layout'
19import { chooseSidebarLayout } from './sidebar'
20import { encodeFrame } from './raster'
21import { bandTree, sidebarTree } from './render'
22import type { MeterScheme } from './schemes'
23import { METER_CHOICES, SCHEMES } from './schemes'
24import { FRAME_SIZE, MINI_SIZE, PALETTE, SHEETS } from './sprites.generated'
25import type { Gain, Tracker } from './tracker'
26import type { TraceEntry } from './trace'
27import { appendTrace, describeTrace, parseTrace, programOf } from './trace'
28import { callEnded, callStarted, compacted, completeTurn, costMeasured, createTracker, intervalNow, metersMeasured, setPlanMode, staminaMeasured, startTurn, tick, tokensSpent, toolFinished, toolStarted } from './tracker'
29import type { Usage } from './view'
30import { buildView, frameAt, mergeUsage, NAMES, percentLeft, sheetIdFor, usageFrom } from './view'
31import { emptyGuild, parseGuild } from './xp'
32
33const STORE_KEY = 'guild.v1'
34const TICK_MS = 50
35// How often the session's figures are read again, whatever the engine reports: a report may never come.
36const USAGE_EVERY_MS = 2_000
37
38const viewAtom = atom({ plugin: 'guild-monitor', key: 'view' } as const, null)
39// Whether the side panel is up, so the band yields to it.
40const panelAtom = atom({ plugin: 'guild-monitor', key: 'panel' } as const, false)
41// The color scheme of the meters: what is read from it is the name, and a change of it draws the screen again.
42const metersAtom = atom({ plugin: 'guild-monitor', key: 'meters' } as const, '')
43
44const PANEL_ID = 'guild'
45// Wide enough for the full character with its stats beside it (see `STATS_COLUMNS`).
46const PANEL_COLUMNS = 62
47// What the person chose about the interface (the panel, the meters' scheme), kept between sessions: preferences only.
48const UI_KEY = 'guild.ui.v1'
49// The record of who was on screen at each step, kept to investigate the choice of character later.
50const TRACE_KEY = 'guild.trace.v1'
51
52const isClosedByPerson = (stored: unknown): boolean =>
53 typeof stored === 'object' && stored !== null && (stored as { panel?: unknown }).panel === false
54
55// What a Raster of the band shows right now, so the clock only repaints it
56// when the frame or the animation changes.
57type Mounted = { who: TaskClass | 'bug'; size: Size; sheetId: string; frame: number }
58
59type Spec = { sheetId: string; frame: number }
60
61const positive = (value: unknown, fallback: number): number =>
62 typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback
63
64// Frames are encoded the first time they are drawn and kept.
65const encoded = new Map<string, string>()
66
67const cellsOf = (sheetId: string, size: Size, frame: number): string | undefined => {
68 const key = `${sheetId}/${size}/${frame}`
69 const hit = encoded.get(key)
70 if (hit !== undefined) return hit
71
72 const sheet = SHEETS[sheetId]
73 const pixels = size === 'full' ? sheet?.frames[frame] : sheet?.mini[frame]
74 if (pixels === undefined) return undefined
75
76 const side = size === 'full' ? FRAME_SIZE : MINI_SIZE
77 const cells = encodeFrame(pixels, side, side, PALETTE)
78 encoded.set(key, cells)
79
80 return cells
81}
82
83// Which animation a character plays and which of its frames shows at `now`.
84const specFor = (who: TaskClass | 'bug', view: ViewModel, tracker: Tracker, now: number): Spec => {
85 if (who === 'bug') {
86 const defeated = view.bug === 'defeated'
87 const sheetId = sheetIdFor('bug', defeated ? 'derrotado' : 'parado')
88 const sheet = SHEETS[sheetId]
89 const elapsed = defeated ? now - tracker.bug.since : now
90
91 return { sheetId, frame: sheet === undefined ? 0 : frameAt(sheet.durations, elapsed, sheet.loop) }
92 }
93
94 const sheetId = sheetIdFor(who, view.chars.find(char => char.cls === who)?.anim ?? 'parado')
95 const sheet = SHEETS[sheetId]
96
97 return { sheetId, frame: sheet === undefined ? 0 : frameAt(sheet.durations, now, sheet.loop) }
98}
99
100export type PublishCache = { signature: string }
101
102// The band redraws when the view it reads changes, so a view is published as soon as an event
103// changes what it is made of, not at the next tick; one equal to the last published is not written.
104const publishView = async ($: StateDollar, view: ViewModel, cache: PublishCache): Promise<void> => {
105 const signature = JSON.stringify(view)
106 if (signature === cache.signature) return
107
108 cache.signature = signature
109 await update($, viewAtom, () => view)
110}
111
112// The monitor only watches: a view that cannot be published must never change what a tool call does or
113// returns. The next publish starts again from a clean cache.
114export const publishQuietly = async ($: StateDollar, make: () => ViewModel | Promise<ViewModel>, cache: PublishCache): Promise<void> => {
115 try {
116 await publishView($, await make(), cache)
117 } catch {
118 cache.signature = ''
119 }
120}
121
122// A gain is saved with the guild and a new level is announced.
123const applyGain = async ($: Pick<CoreEngineInterface, 'store' | 'ui'>, tracker: Tracker, gain: Gain | undefined): Promise<void> => {
124 if (gain === undefined) return
125
126 await $.store.set(STORE_KEY, tracker.guild)
127 if (gain.leveledUp) $.ui.toast(`${NAMES[gain.cls]} subiu para o nível ${gain.level}!`)
128}
129
130type PanelDollar = Pick<CoreEngineInterface, 'store' | 'ui' | 'state'>
131
132// One preference saved, the others kept.
133const saveUi = async ($: Pick<CoreEngineInterface, 'store'>, field: string, value: unknown): Promise<void> => {
134 await $.store.set(UI_KEY, mergeSaved(await $.store.get(UI_KEY), field, value))
135}
136
137// Opens the pane, which the engine seats beside the transcript when there is room and
138// otherwise leaves undrawn. `remember` keeps it open for the next sessions: a command
139// of the person's does, the opening at the start does not.
140const showPanel = async ($: PanelDollar, remember: boolean): Promise<{ isPlaced: boolean; reason?: string }> => {
141 const opened = await $.ui.open({ id: PANEL_ID, title: 'Guilda', columns: PANEL_COLUMNS })
142 const isPlaced = opened.isPlaced === true
143
144 await update($, panelAtom, () => isPlaced)
145 if (isPlaced && remember) await saveUi($, 'panel', true)
146
147 return { isPlaced, reason: 'reason' in opened ? String(opened.reason) : undefined }
148}
149
150const saveTrace = async ($: Pick<CoreEngineInterface, 'store'>, trace: readonly TraceEntry[]): Promise<void> => {
151 await $.store.set(TRACE_KEY, trace)
152}
153
154const hidePanel = async ($: PanelDollar): Promise<void> => {
155 await update($, panelAtom, () => false)
156 await saveUi($, 'panel', false)
157}
158
159export const register: Register = (on, options) => {
160 const settings: Settings = {
161 sleepAfterMs: positive(options.sleepAfterSeconds, DEFAULTS.sleepAfterMs / 1000) * 1000,
162 tiredAtPercent: positive(options.tiredAtPercent, DEFAULTS.tiredAtPercent),
163 }
164 const bandSize = options.bandSize === 'compact' ? 'compact' : 'full'
165 // The forecast of the windows: on unless the person turned it off (anything else counts as on).
166 const showForecast = options.showForecast !== 'off'
167 // The toasts of the guild (a meter running low, the rest after a compaction): on unless the person turned them off
168 // (anything else counts as on).
169 const showAlerts = alertsOn(options.alerts)
170 const layoutMode = options.layout === 'bar' || options.layout === 'panel' ? options.layout : 'auto'
171 // A demo scene shows made-up figures and ignores the session.
172 let scene = sceneOf(options.demo)
173 // The meters' scheme: the choice saved by `/guild meters` wins over the option; with neither, `padrao`.
174 let meters: MeterScheme = chooseFrom(METER_CHOICES, [options.meters])
175 // A scheme scene forces its scheme while it runs, and saves nothing.
176 const colorsNow = () => SCHEMES[(scene === undefined ? undefined : schemeOfScene(scene)) ?? meters]
177
178 let tracker: Tracker = createTracker(emptyGuild(), 0)
179 let usage: Usage = { rateLimits: [] }
180 // The recent readings of the rate limits, for the forecast: in memory only, never saved.
181 let history: History = EMPTY_HISTORY
182 let trace: TraceEntry[] = []
183 let bandId: string | undefined
184 // False until the guild is read from the store: before that the band keeps showing the last view.
185 let isReady = false
186 // Identifies each call in progress, so parallel calls end one by one.
187 let nextCallId = 0
188 const mounted = new Map<string, Mounted>()
189
190 const cache: PublishCache = { signature: '' }
191
192 const stateAt = (now: number): { tracker: Tracker; usage: Usage; history: History } =>
193 scene === undefined ? { tracker, usage, history } : demoState(scene, now, settings)
194
195 // What the view shows of the forecast comes from `history`, and from nothing when the option is off.
196 const viewOf = (from: Tracker, figures: Usage, now: number, readings: History = history): ViewModel =>
197 buildView(from, figures, now, settings, showForecast ? readings : EMPTY_HISTORY)
198
199 // A measurement of the context brings the message of a compaction waiting for it. What is said with it is behind
200 // `alerts` (see `speak`): this keeps following the compaction whether or not it is shown.
201 const stamina = (contextPercent: number | undefined): string | undefined => {
202 const done = staminaMeasured(tracker, percentLeft(contextPercent))
203 tracker = done.tracker
204
205 return done.announce === undefined ? undefined : compactionToast(done.announce)
206 }
207
208 // The warnings that a measurement of the three meters brings: HP, MP and stamina below a band. None in a demonstration
209 // scene (which shows made-up figures) and none for a figure the engine does not have.
210 const meterWarnings = (now: number): string[] => {
211 if (scene !== undefined) return []
212
213 const week = usage.rateLimits.find(limit => limit.kind === 'seven_day')
214 const hours = usage.rateLimits.find(limit => limit.kind === 'five_hour')
215 const done = metersMeasured(tracker, {
216 hp: { value: percentLeft(week?.percentUsed), resetsAt: week?.resetsAt },
217 mp: { value: percentLeft(hours?.percentUsed), resetsAt: hours?.resetsAt },
218 st: { value: percentLeft(usage.contextPercent) },
219 })
220 tracker = done.tracker
221
222 return done.fires.map(fire => alertText(fire.meter, fire.band, fire.value, resetText((fire.meter === 'hp' ? week : fire.meter === 'mp' ? hours : undefined)?.resetsAt ?? null, now)))
223 }
224
225 // What a measurement has to say, to be toasted: the rest message of a compaction and then the warnings. `alerts` off
226 // says nothing.
227 const speak = (rested: string | undefined, now: number): string[] => (showAlerts ? [...(rested === undefined ? [] : [rested]), ...meterWarnings(now)] : [])
228
229 // Only what the engine just reported is a reading: the known figures that `mergeUsage` keeps for a window
230 // the engine did not report again would count as readings in which nothing changed.
231 const record = (fresh: Usage, now: number): void => {
232 if (showForecast) history = recordReadings(history, fresh.rateLimits, now)
233 }
234
235 on('session.start', async ($, e, next) => {
236 await $.command.register({
237 name: 'guild',
238 description: 'Abre ou fecha o painel lateral da guilda, troca o esquema de cores dos medidores e mostra cenas de demonstração',
239 argumentHint: `[panel|bar|trace|meters [${METER_CHOICES.names.join('|')}]|${[...SCENES, ...SCHEME_SCENES].join('|')}|off]`,
240 })
241 const startedAt = await $.clock.now()
242 tracker = createTracker(parseGuild(await $.store.get(STORE_KEY)), startedAt)
243 const first = usageFrom(await $.session.usage())
244 usage = mergeUsage(usage, first)
245 history = EMPTY_HISTORY
246 record(first, startedAt)
247 trace = parseTrace(await $.store.get(TRACE_KEY))
248 isReady = true
249 cache.signature = ''
250
251 // The panel opens by itself where there is room (the engine decides), unless the person closed it.
252 const kept = await $.store.get(UI_KEY)
253 meters = chooseFrom(METER_CHOICES, [savedChoice(METER_CHOICES, kept), options.meters])
254 await update($, metersAtom, () => meters)
255 const wantsPanel = layoutMode === 'panel' || (layoutMode === 'auto' && !isClosedByPerson(kept))
256 if (wantsPanel) await showPanel($, false)
257
258 let isBusy = false
259 let nextUsageAt = 0
260
261 $.clock.every(TICK_MS, async () => {
262 if (isBusy) return
263 isBusy = true
264
265 try {
266 const now = await $.clock.now()
267 tracker = tick(tracker, now)
268
269 if (now >= nextUsageAt) {
270 nextUsageAt = now + USAGE_EVERY_MS
271 // The interval is the one at the moment of asking: a call that ends while the answer is on its way is the next one's.
272 const asked = intervalNow(tracker)
273 const fresh = usageFrom(await $.session.usage())
274 usage = mergeUsage(usage, fresh)
275 tracker = costMeasured(tracker, fresh.costUsd, asked)
276 for (const text of speak(stamina(fresh.contextPercent), now)) $.ui.toast(text)
277 record(fresh, now)
278 }
279
280 const state = stateAt(now)
281 const view = viewOf(state.tracker, state.usage, now, state.history)
282 await publishView($, view, cache)
283
284 if (bandId === undefined) return
285
286 for (const [key, shown] of mounted) {
287 const spec = specFor(shown.who, view, state.tracker, now)
288 if (spec.sheetId === shown.sheetId && spec.frame === shown.frame) continue
289
290 const cells = cellsOf(spec.sheetId, shown.size, spec.frame)
291 if (cells === undefined) continue
292
293 const result = await $.ui.blit({ requestId: bandId, key, cells })
294 if (!('deny' in result && result.deny)) {
295 shown.sheetId = spec.sheetId
296 shown.frame = spec.frame
297 }
298 }
299 } finally {
300 isBusy = false
301 }
302 })
303
304 return next(e)
305 })
306
307 // `/guild bug` shows that scene at once; `/guild off` goes back to the real session.
308 on('command.run', { command: 'guild' }, async ($, e) => {
309 const word = e.args.trim().toLowerCase()
310
311 if (word === 'panel') {
312 const opened = await showPanel($, true)
313
314 return { text: opened.isPlaced ? 'Painel aberto' : `Sem espaço para o painel: ${opened.reason}` }
315 }
316
317 if (word === 'trace' || word.startsWith('trace ')) {
318 const arg = word.slice('trace'.length).trim()
319
320 if (arg === 'clear') {
321 trace = []
322 await saveTrace($, trace)
323
324 return { text: 'Registro apagado.' }
325 }
326
327 const count = Number.parseInt(arg, 10)
328
329 return { text: describeTrace(trace, Number.isFinite(count) && count > 0 ? count : 20) }
330 }
331
332 if (word === 'bar') {
333 await $.ui.close({ id: PANEL_ID })
334
335 return { text: 'Painel fechado: a barra inferior voltou' }
336 }
337
338 if (word === 'meters' || word.startsWith('meters ')) {
339 const request = requestOf(METER_CHOICES, word.slice('meters'.length))
340
341 if (request.kind === 'list') return { text: describeChoices(METER_CHOICES, meters) }
342 if (request.kind === 'invalid') return { text: helpText(METER_CHOICES, request.typed) }
343
344 meters = request.name
345 await saveUi($, METER_CHOICES.field, meters)
346 await update($, metersAtom, () => meters)
347
348 // A scheme scene keeps its own scheme on screen: the choice is saved, and the answer says it is not showing yet.
349 const forced = scene === undefined ? undefined : schemeOfScene(scene)
350 const note = forced !== undefined && forced !== meters ? `, mas a cena ${scene} mostra o esquema dela até /guild off` : ''
351
352 return { text: `${savedText(METER_CHOICES, meters)}${note}` }
353 }
354
355 const choice = choiceOf(e.args)
356
357 if (choice.kind === 'unknown') return { text: `Use /guild panel | bar | trace [N|clear] | meters [${METER_CHOICES.names.join('|')}] | ${[...SCENES, ...SCHEME_SCENES].join(' | ')} | off` }
358
359 scene = choice.kind === 'scene' ? choice.scene : undefined
360
361 return { text: choice.kind === 'scene' ? `Cena: ${choice.scene}` : 'Sessão real' }
362 })
363
364 on('turn.start', async ($, e, next) => {
365 if (scene !== undefined) return next(e)
366
367 const now = await $.clock.now()
368 tracker = startTurn(tracker, { id: e.turnId, prompt: e.text, now })
369 trace = appendTrace(trace, { at: now, what: 'turn', tool: 'início (palpite do prompt)', shown: tracker.active, planMode: tracker.planMode })
370 await saveTrace($, trace)
371 await publishView($, viewOf(tracker, usage, now), cache)
372
373 return next(e)
374 })
375
376 // The monitor only watches: if it fails, the call goes on as if it were not there.
377 on('tool.call', async ($, e, next) => {
378 if (e.agentId !== undefined || scene !== undefined) return next(e)
379
380 const input: Record<string, unknown> = { ...e }
381 const use: ToolUse = {
382 tool: e.tool,
383 command: typeof input.command === 'string' ? input.command : undefined,
384 subagent: typeof input.subagent_type === 'string' ? input.subagent_type : undefined,
385 lines: linesWritten(input),
386 file: fileOf(e.tool, input),
387 }
388
389 const startedAt = await $.clock.now()
390 // The target is for the screen only: it goes into the tracker's memory and nowhere else.
391 const callId = (nextCallId += 1)
392 tracker = callStarted(toolStarted(tracker, use, startedAt), { id: callId, tool: use.tool, target: targetOf(use.tool, input) })
393 await publishQuietly($, () => viewOf(tracker, usage, startedAt), cache)
394
395 let ran: Awaited<ReturnType<typeof next>>
396 try {
397 ran = await next(e)
398 } catch (error) {
399 tracker = callEnded(tracker, callId)
400 await publishQuietly($, async () => viewOf(tracker, usage, await $.clock.now()), cache)
401 throw error
402 }
403
404 // A refused call is not in progress: it leaves the line at once.
405 tracker = callEnded(tracker, callId)
406
407 // What follows only records the call: whatever fails there, the tool's own result goes back as it is.
408 try {
409 // A call a hook or a permission refused did not fail on its own: it neither raises nor defeats a bug.
410 if ('deny' in ran && ran.deny) {
411 await publishQuietly($, async () => viewOf(tracker, usage, await $.clock.now()), cache)
412 } else {
413 const finishedAt = await $.clock.now()
414 const rejected = isRejection({ tool: use.tool, isError: ran.isError === true, text: ran.text })
415 const done = toolFinished(tracker, {
416 use,
417 isError: ran.isError === true,
418 rejected,
419 now: finishedAt,
420 durationMs: finishedAt - startedAt,
421 })
422 tracker = done.tracker
423 trace = appendTrace(trace, {
424 at: finishedAt,
425 what: 'tool',
426 tool: use.tool,
427 program: use.command === undefined ? undefined : programOf(use.command),
428 kind: kindOf(use),
429 shown: tracker.active,
430 paid: done.gain?.cls ?? (ran.isError === true ? undefined : classOf(use, tracker.planMode)),
431 planMode: tracker.planMode,
432 isError: ran.isError === true && !rejected ? true : undefined,
433 rejected: rejected ? true : undefined,
434 })
435 await saveTrace($, trace)
436 await applyGain($, tracker, done.gain)
437 await publishQuietly($, () => viewOf(tracker, usage, finishedAt), cache)
438 }
439 } catch {
440 cache.signature = ''
441 }
442
443 return ran
444 }).catch((_$, e, next) => next(e))
445
446 on('turn.complete', async ($, e, next) => {
447 if (scene !== undefined) return next(e)
448
449 const now = await $.clock.now()
450 // What the turn spent: a subagent's turn only adds to its own total and goes no further.
451 tracker = tokensSpent(tracker, e.usage, e.agentId !== undefined)
452 if (e.agentId !== undefined) {
453 await publishView($, viewOf(tracker, usage, now), cache)
454
455 return next(e)
456 }
457
458 const done = completeTurn(tracker, { id: e.turnId, reason: e.reason, now, durationMs: e.durationMs })
459 tracker = done.tracker
460 await publishView($, viewOf(tracker, usage, now), cache)
461
462 await applyGain($, tracker, done.gain)
463
464 return next(e)
465 })
466
467 on('session.measure', async ($, e, next) => {
468 const asked = intervalNow(tracker)
469 const now = await $.clock.now()
470 usage = mergeUsage(usage, usageFrom(e))
471 tracker = costMeasured(tracker, usageFrom(e).costUsd, asked)
472 for (const text of speak(stamina(usageFrom(e).contextPercent), now)) $.ui.toast(text)
473 record(usageFrom(e), now)
474 await publishView($, viewOf(tracker, usage, now), cache)
475
476 return next(e)
477 })
478
479 // The conversation was compacted. The monitor only watches: the compaction's own result goes back as it is.
480 on('session.compact', async ($, e, next) => {
481 // The stamina known before it: a measurement that arrives during the compaction is not yet a recovery.
482 const before = percentLeft(usage.contextPercent)
483 const result = await next(e)
484
485 try {
486 if (scene === undefined && countsAsCompaction({ trigger: e.trigger, agentId: e.agentId, skipped: result.skip !== undefined })) {
487 tracker = compacted(tracker, before)
488 await publishQuietly($, async () => viewOf(tracker, usage, await $.clock.now()), cache)
489 }
490 } catch {
491 cache.signature = ''
492 }
493
494 return result
495 })
496
497 // The footer's mode labels are the only place the engine says plan mode is on.
498 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
499 const planMode = e.props.modes.some(mode => /plan/i.test(mode))
500 const changed = planMode !== tracker.planMode
501 tracker = setPlanMode(tracker, planMode)
502
503 // Written to the store with the next event: a drawing does not write.
504 if (changed) {
505 trace = appendTrace(trace, { at: await $.clock.now(), what: 'mode', tool: planMode ? 'plano ligado' : 'plano desligado', shown: tracker.active, planMode })
506 }
507
508 return next(e)
509 })
510
511 // The side panel: the same guild as the band, stacked, drawn where the engine seats the pane.
512 on('ui.render', { component: 'Pane', requestId: PANEL_ID }, async ($, e, next) => {
513 if (e.surface !== 'terminal') return next(e)
514
515 // Reading the atoms draws the panel again when the view or the meters' scheme changes.
516 const published = await read($, viewAtom)
517 await read($, metersAtom)
518 const now = await $.clock.now()
519 const state = stateAt(now)
520 const view = isReady ? viewOf(state.tracker, state.usage, now, state.history) : published
521 if (view === null) return next(e)
522
523 const layout = chooseSidebarLayout(e.props.bodyColumns, bandSize, view.bug)
524
525 bandId = e.requestId
526 mounted.clear()
527
528 const tree = sidebarTree($.ui.resolve(e), {
529 view,
530 columns: e.props.bodyColumns,
531 layout,
532 now,
533 colors: colorsNow(),
534 cellsFor: (key, who, size) => {
535 const spec = specFor(who, view, state.tracker, now)
536 mounted.set(key, { who, size, sheetId: spec.sheetId, frame: spec.frame })
537
538 return cellsOf(spec.sheetId, size, spec.frame) ?? ''
539 },
540 })
541
542 return tree ?? next(e)
543 })
544
545 // Closed by the person (the engine's × mark) or by `/guild bar`: the band comes back and stays.
546 on('ui.close', { id: PANEL_ID }, async ($, e, next) => {
547 if (e.origin.kind !== 'unload') await hidePanel($)
548
549 return next(e)
550 })
551
552 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
553 if (e.surface !== 'terminal' || e.props.hasSurvey || layoutMode === 'panel') return next(e)
554
555 // While the side panel is up it is the guild's place; the band is its fallback.
556 if (await read($, panelAtom)) return next(e)
557
558 // Reading the atom is what draws the band again when it changes; what is drawn is the view
559 // of this very moment, so the character, its XP and the figures always agree.
560 const published = await read($, viewAtom)
561 await read($, metersAtom)
562 const now = await $.clock.now()
563 const state = stateAt(now)
564 const view = isReady ? viewOf(state.tracker, state.usage, now, state.history) : published
565 if (view === null) return next(e)
566
567 const layout = chooseLayout(e.props.bodyColumns, e.props.maxRows, bandSize, view.bug)
568 if (layout === null) return next(e)
569
570 bandId = e.requestId
571 mounted.clear()
572
573 const tree = bandTree($.ui.resolve(e), {
574 view,
575 layout,
576 now,
577 colors: colorsNow(),
578 cellsFor: (key, who, size) => {
579 const spec = specFor(who, view, state.tracker, now)
580 mounted.set(key, { who, size, sheetId: spec.sheetId, frame: spec.frame })
581
582 return cellsOf(spec.sheetId, size, spec.frame) ?? ''
583 },
584 })
585
586 return tree ?? next(e)
587 })
588}
589hooks/classify.ts 211 lines1export type TaskClass = 'mago' | 'guerreiro' | 'explorador'
2
3// One tool call of a turn, as the hook saw it: the tool, and for Bash its
4// command, for Agent the kind of subagent.
5export type ToolUse = { tool: string; command?: string; subagent?: string; lines?: number; file?: string }
6
7export type TurnSignals = {
8 prompt: string
9 planMode: boolean
10 tools: readonly ToolUse[]
11}
12
13const MUTATING = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
14const LOOKING = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebSearch', 'WebFetch', 'ToolSearch', 'NotebookRead'])
15const PLANNING = new Set(['TodoWrite', 'EnterPlanMode', 'ExitPlanMode', 'AskUserQuestion', 'TaskCreate', 'TaskUpdate'])
16const READ_COMMANDS = new Set([
17 'ls', 'cat', 'head', 'tail', 'grep', 'rg', 'find', 'wc', 'pwd', 'which', 'tree', 'stat', 'file', 'du', 'df',
18 'echo', 'printf', 'sort', 'uniq', 'diff', 'jq', 'env', 'whoami', 'date', 'type', 'cd',
19 'cut', 'tr', 'nl', 'column', 'basename', 'dirname', 'realpath', 'readlink', 'uname', 'hostname', 'id', 'ps', 'lsof',
20 'less', 'more',
21])
22const READ_GIT = new Set(['log', 'status', 'diff', 'show', 'branch', 'blame', 'remote', 'ls-files', 'rev-parse', 'describe'])
23// Subcommands that only read, by program: `gh pr view`, `claude plugin list`.
24const READ_GH = new Set(['pr view', 'pr list', 'pr diff', 'pr checks', 'pr status', 'issue view', 'issue list', 'repo view'])
25const READ_CLAUDE = new Set(['plugin list', '--version'])
26
27const words = (text: string): string[] => text.trim().split(/\s+/).filter(Boolean)
28
29// Splits at | || && ; that are not inside quotes, so `sed -n '1p;3p'` and
30// `grep -E 'a|b'` stay one piece. A backslash outside single quotes copies the
31// next character as is. Undefined when a quote is left open: we cannot tell what
32// the shell would run, so the caller treats it as not read-only.
33const pieces = (command: string): string[] | undefined => {
34 const result: string[] = []
35 let current = ''
36 let quote = ''
37
38 for (let i = 0; i < command.length; i += 1) {
39 const char = command[i]
40
41 if (char === '\\' && quote !== "'") {
42 current += char + (command[i + 1] ?? '')
43 i += 1
44 } else if (quote) {
45 if (char === quote) quote = ''
46 current += char
47 } else if (char === "'" || char === '"') {
48 quote = char
49 current += char
50 } else if (char === '|' || char === ';' || (char === '&' && command[i + 1] === '&')) {
51 result.push(current)
52 current = ''
53 if (command[i + 1] === char) i += 1
54 } else {
55 current += char
56 }
57 }
58
59 return quote ? undefined : [...result, current]
60}
61
62// `sed` edits in place with -i, alone or inside a cluster (-ni, -i.bak), or --in-place.
63const sedEditsInPlace = (args: string[]): boolean => args.some(arg => /^-[A-Za-z]*i/.test(arg) || arg.startsWith('--in-place'))
64
65// `awk` reads unless its program runs a command (system, getline, a pipe), comes
66// from a file we cannot see (-f, -E) or edits in place (-i). Writing to a file is a
67// redirection, already caught for the whole command.
68const awkRunsCode = (args: string[], text: string): boolean =>
69 /\bsystem\s*\(|\bgetline\b|\|/.test(text) || args.some(arg => /^(-f|--file|-i|--include|-E|--exec)/.test(arg))
70
71// `hostname name` sets the name; only flags and no argument read it.
72const hostnameSets = (args: string[]): boolean => args.some(arg => !arg.startsWith('-'))
73
74// `less -o file` and `-O file` write the input to a file.
75const lessWrites = (args: string[]): boolean => args.some(arg => /^-[A-Za-z]*[oO]/.test(arg) || /^--log-file/i.test(arg))
76
77const isReadPart = (text: string): boolean => {
78 const [head = '', ...args] = words(text)
79 const [second = '', third = ''] = args
80
81 if (head === 'git') return READ_GIT.has(second)
82 if (head === 'gh') return READ_GH.has(`${second} ${third}`)
83 if (head === 'claude') return READ_CLAUDE.has(`${second} ${third}`.trim())
84 if (head === 'sed') return !sedEditsInPlace(args)
85 if (head === 'awk') return !awkRunsCode(args, text)
86 if (head === 'hostname') return !hostnameSets(args)
87 if (head === 'less') return !lessWrites(args)
88
89 return READ_COMMANDS.has(head)
90}
91
92// A command only looks when every piece of a pipeline or && chain does and
93// nothing is redirected into a file.
94export const isReadOnlyCommand = (raw: string): boolean => {
95 const command = raw.replace(/\d*>>?\s*\/dev\/null/g, '')
96 if (/(^|[^>])>{1,2}(?!&)|\btee\b/.test(command)) return false
97
98 const parts = pieces(command)
99 if (!parts) return false
100
101 return parts.filter(part => words(part).length > 0).every(isReadPart)
102}
103
104// What a tool call counts as: acting changes things, looking reads them,
105// planning organises the work.
106export const kindOf = (use: ToolUse): 'act' | 'look' | 'plan' => {
107 if (MUTATING.has(use.tool)) return 'act'
108 if (LOOKING.has(use.tool)) return 'look'
109 if (PLANNING.has(use.tool)) return 'plan'
110 if (use.tool === 'Bash') return isReadOnlyCommand(use.command ?? '') ? 'look' : 'act'
111 if (use.tool === 'Agent' || use.tool === 'Task') {
112 if (use.subagent === 'Plan') return 'plan'
113 if (use.subagent === 'Explore') return 'look'
114
115 return 'act'
116 }
117
118 return 'act'
119}
120
121// Words of a prompt, with a weight each. Fixing and building words weigh most
122// on the acting side, investigating words on the looking side, and a failure
123// noun alone (bug, error) only leans a little towards looking, so "corrija o
124// bug" is the warrior and "investigue o bug" the explorer.
125const FIX_WORDS =
126 /\b(fix|corrig\w*|corrij\w*|consert\w*|arrum\w*|resolv\w*|solve|implement\w*|refator\w*|refactor\w*)\b/g
127const TASK_WORDS =
128 /\b(crie|criar|cria|adicion\w*|add|escrev\w*|write|renome\w*|rename|delet\w*|remov\w*|instal\w*|build|compil\w*|rode|rodar|execut\w*|deploy|commit|push|test|tests|teste|testes|testar)\b/g
129const INVESTIGATE_WORDS = /\b(investig\w*|debug\w*|depur\w*|diagnos\w*)\b/g
130const LOOK_WORDS =
131 /\b(search|find|explor\w*|pesquis\w*|procur\w*|busc\w*|onde|where|explain|explic\w*|entend\w*|understand|mostr\w*|show|list\w*)\b/g
132const WEB_WORDS = /\b(internet|web|online|site|sites|documentacao|docs)\b/g
133const FAILURE_WORDS = /\b(bug|bugs|erro|erros|error|errors|falh\w*|fail\w*)\b/g
134const PLAN_WORDS = /\b(plan\w*|planej\w*|plano|planos|arquitet\w*|architect\w*|brainstorm\w*|spec|proposta|estrateg\w*|strateg\w*|roadmap)\b/g
135const SOFT_PLAN_WORDS = /\b(design|desenho)\b/g
136
137const normalize = (text: string): string =>
138 text
139 .toLowerCase()
140 .normalize('NFD')
141 .replace(/[̀-ͯ]/g, '')
142
143const count = (text: string, pattern: RegExp): number => text.match(pattern)?.length ?? 0
144
145// What the prompt alone says the work is, if it says; a tie goes to acting,
146// then planning.
147export const classifyPrompt = (prompt: string): TaskClass | undefined => {
148 const text = normalize(prompt)
149 const act = count(text, FIX_WORDS) * 3 + count(text, TASK_WORDS) * 2
150 const plan = count(text, PLAN_WORDS) * 4 + count(text, SOFT_PLAN_WORDS) * 2
151 const look = count(text, INVESTIGATE_WORDS) * 3 + count(text, LOOK_WORDS) * 2 + count(text, WEB_WORDS) * 2 + count(text, FAILURE_WORDS)
152 const top = Math.max(act, plan, look)
153
154 if (top === 0) return undefined
155 if (act === top) return 'guerreiro'
156 if (plan === top) return 'mago'
157
158 return 'explorador'
159}
160
161// What a turn is when nothing says otherwise: the warrior is for fixing and
162// doing, so the neutral class is the one that looks around.
163export const DEFAULT_CLASS: TaskClass = 'explorador'
164
165const CLASS_OF = { act: 'guerreiro', look: 'explorador', plan: 'mago' } as const
166
167// The class of what the tools were doing: the one most of them did, a tie going
168// to the most recent. Undefined without tools.
169export const classifyTools = (tools: readonly ToolUse[]): TaskClass | undefined => {
170 const counts: Record<TaskClass, number> = { mago: 0, guerreiro: 0, explorador: 0 }
171 let last: TaskClass | undefined
172
173 for (const use of tools) {
174 last = CLASS_OF[kindOf(use)]
175 counts[last] += 1
176 }
177
178 const top = Math.max(counts.mago, counts.guerreiro, counts.explorador)
179
180 return last === undefined ? undefined : counts[last] === top ? last : (Object.keys(counts) as TaskClass[]).find(cls => counts[cls] === top)
181}
182
183const linesOf = (text: unknown): number => (typeof text === 'string' && text.length > 0 ? text.split('\n').length : 0)
184
185// Lines a call writes, as far as its input shows: the new text of an edit or a new file.
186export const linesWritten = (input: Record<string, unknown>): number => {
187 const edits = Array.isArray(input.edits) ? input.edits : []
188 const inEdits = edits.reduce<number>(
189 (sum, edit) => sum + (typeof edit === 'object' && edit !== null ? linesOf((edit as Record<string, unknown>).new_string) : 0),
190 0,
191 )
192
193 return linesOf(input.new_string) + linesOf(input.content) + inEdits
194}
195
196// Who a single tool call belongs to: plan mode is the mage's, otherwise its kind.
197export const classOf = (use: ToolUse, planMode: boolean): TaskClass => (planMode ? 'mago' : CLASS_OF[kindOf(use)])
198
199// How many of the latest tool calls say what the agent is doing now.
200const RECENT_TOOLS = 3
201
202// What the agent is doing decides: plan mode is the mage's, otherwise the kind of
203// its latest tool calls (reading is the explorer's, changing things the
204// warrior's, organising the work the mage's). The prompt only guesses before the
205// first tool call, and the explorer is the default.
206export const classify = (signals: TurnSignals): TaskClass => {
207 if (signals.planMode) return 'mago'
208
209 return classifyTools(signals.tools.slice(-RECENT_TOOLS)) ?? classifyPrompt(signals.prompt) ?? DEFAULT_CLASS
210}
211hooks/alerts.ts 85 lines1import { sameWindow } from './forecast'
2import { untilReset } from './view'
3
4// The alerts for a meter running low (HP, MP, stamina): when a value crosses a band, and what the toast says.
5// Everything here is pure; the "already warned" memory lives in the tracker and is never stored.
6export type Band = 20 | 10
7
8// From the lowest band up: the one that fires is the lowest the value is below.
9const BANDS: readonly Band[] = [10, 20]
10
11// A band warns again only after the value goes back above it by this many points (so 25 for band 20, 15 for band 10).
12export const REARM_POINTS = 5
13
14// The `alerts` option: off only for the exact word `off`; anything else (missing, unknown, another case) counts as on.
15export const alertsOn = (value: unknown): boolean => value !== 'off'
16
17export type MeterId = 'hp' | 'mp' | 'st'
18
19// A meter in percent left. `value` is null when the engine has none (rate limits off a subscription).
20export type Reading = { value: number | null; resetsAt?: string | null }
21
22export type MeterAlert = {
23 // The last value read; null before any.
24 value: number | null
25 // The reset time of the window the warnings belong to (null while unknown).
26 resetsAt: string | null
27 // The bands already warned in this cycle.
28 warned: readonly Band[]
29}
30
31export const METERS: readonly MeterId[] = ['hp', 'mp', 'st']
32
33export type Alerts = Record<MeterId, MeterAlert>
34
35export const NO_METER: MeterAlert = { value: null, resetsAt: null, warned: [] }
36
37export const NO_ALERTS: Alerts = { hp: NO_METER, mp: NO_METER, st: NO_METER }
38
39// The warnings forgotten: the next reading below a band warns again.
40export const rearm = (state: MeterAlert): MeterAlert => ({ ...state, warned: [] })
41
42// One reading of a meter: the band that fires, if any, and the memory for the next one. A value that does not
43// exist fires nothing and re-arms nothing. A drop past both bands fires only the lower one and marks both.
44export const crossing = (state: MeterAlert, reading: Reading): { state: MeterAlert; fire?: Band } => {
45 const { value } = reading
46 if (value === null || !Number.isFinite(value)) return { state }
47
48 const isNewWindow = reading.resetsAt != null && state.resetsAt !== null && !sameWindow(state.resetsAt, reading.resetsAt)
49 const kept = (isNewWindow ? [] : state.warned).filter(band => value < band + REARM_POINTS)
50 const below = BANDS.filter(band => value < band)
51 const fire = below.find(band => !kept.includes(band))
52
53 return { state: { value, resetsAt: reading.resetsAt ?? state.resetsAt, warned: [...new Set([...kept, ...below])] }, fire }
54}
55
56const NAME: Record<MeterId, string> = { hp: 'O Guerreiro', mp: 'O Mago', st: 'O Explorador' }
57// "quase sem vida" and "com a vida por um fio"
58const THING: Record<MeterId, { without: string; withIt: string }> = {
59 hp: { without: 'vida', withIt: 'a vida' },
60 mp: { without: 'mana', withIt: 'a mana' },
61 st: { without: 'fôlego', withIt: 'o fôlego' },
62}
63
64// "1h40", "45min" or "3d 5h": the time to the reset; empty when unknown or past.
65export const resetText = (resetsAt: string | null, now: number): string => {
66 const at = resetsAt === null ? Number.NaN : Date.parse(resetsAt)
67 if (!(at > now)) return ''
68
69 const minutes = Math.ceil((at - now) / 60_000)
70 if (minutes < 24 * 60) return untilReset(resetsAt, now)
71
72 const days = Math.floor(minutes / 1440)
73 const hours = Math.floor((minutes - days * 1440) / 60)
74
75 return hours === 0 ? `${days}d` : `${days}d ${hours}h`
76}
77
78// "O Mago está quase sem mana: 18%, reinicia em 1h40". Only the stamina reminds of `/compact` (it has no reset).
79export const alertText = (meter: MeterId, band: Band, value: number, reset: string): string => {
80 const thing = THING[meter]
81 const head = band === 20 ? `${NAME[meter]} está quase sem ${thing.without}` : `${NAME[meter]} está com ${thing.withIt} por um fio`
82
83 return meter === 'st' ? `${head}: ${value}%. /compact recupera` : `${head}: ${value}%${reset === '' ? '' : `, reinicia em ${reset}`}`
84}
85hooks/compactions.ts 55 lines1// The compactions of the session: how many the main agent made, and the stamina the next measurement is
2// compared with. In memory with the tracker and gone with the session; nothing here is ever stored or traced.
3export type Compactions = {
4 count: number
5 // A compaction whose recovery has not been seen yet: the stamina known before it (null when none was known).
6 // The compaction event does not carry the new percentage, so the message waits for the next measurement.
7 pending: { before: number | null } | null
8}
9
10export const NO_COMPACTIONS: Compactions = { count: 0, pending: null }
11
12export type CompactionEvent = {
13 // What compacts (`session.compact`'s trigger).
14 trigger: string
15 // Present when a subagent (or a fork of the engine) compacts its own transcript.
16 agentId?: string
17 // A hook vetoed it: the conversation stayed as it was.
18 skipped: boolean
19}
20
21// Only the person's `/compact` and the engine's own threshold count, and only for the main agent; a
22// `precompute` installs nothing and a veto compacts nothing.
23export const countsAsCompaction = (event: CompactionEvent): boolean =>
24 event.agentId === undefined && !event.skipped && (event.trigger === 'manual' || event.trigger === 'auto')
25
26// One more compaction. Another one before any measurement keeps the first "before": it is the stamina
27// the guild had before resting.
28export const onCompaction = (state: Compactions, before: number | null): Compactions => ({
29 count: state.count + 1,
30 pending: state.pending ?? { before },
31})
32
33export type Announcement = { before: number; after: number }
34
35// A measurement of the stamina (percent left; null when the engine had none). It closes the pending compaction
36// when the stamina went up (the message), or down (the compaction did not help: nothing to say). An equal figure
37// may be the engine's old reading, so the compaction stays pending for the next one.
38export const onStamina = (state: Compactions, after: number | null): { state: Compactions; announce?: Announcement } => {
39 if (state.pending === null || after === null) return { state }
40
41 const { before } = state.pending
42 if (before !== null && after === before) return { state }
43
44 const closed: Compactions = { count: state.count, pending: null }
45
46 return before !== null && after > before ? { state: closed, announce: { before, after } } : { state: closed }
47}
48
49export const compactionToast = (announce: Announcement): string =>
50 `A guilda descansou: ST recuperada de ${announce.before}% para ${announce.after}%`
51
52// `1 compactação`, `2 compactações`; none while there are none. `short` gives `2 compact.` for a line without room.
53export const compactionSegments = (count: number, short = false): string[] =>
54 !(count > 0) ? [] : short ? [`${count} compact.`] : [`${count} ${count === 1 ? 'compactação' : 'compactações'}`]
55hooks/damage.ts 66 lines1// The material result of the session's work: the distinct files edited, the lines written and the shell
2// commands run. It lives in memory with the tracker and starts over with the session; no path is kept.
3export type Damage = {
4 // A short fingerprint of each edited file, the latest last; never the path itself.
5 files: readonly string[]
6 lines: number
7 commands: number
8}
9
10export const NO_DAMAGE: Damage = { files: [], lines: 0, commands: 0 }
11
12// The most files remembered, so a very long session does not grow without limit.
13export const MAX_FILES = 1_000
14
15// What the screen needs of the damage: only the three numbers.
16export type DamageView = { files: number; lines: number; commands: number }
17
18// A short fingerprint of a path (53 bits, in base 36): it tells two files apart and says nothing of where they are.
19export const fileKey = (path: string): string => {
20 let a = 0xdeadbeef
21 let b = 0x41c6ce57
22
23 for (let i = 0; i < path.length; i += 1) {
24 const code = path.charCodeAt(i)
25 a = Math.imul(a ^ code, 2654435761)
26 b = Math.imul(b ^ code, 1597334677)
27 }
28 a = Math.imul(a ^ (a >>> 16), 2246822507) ^ Math.imul(b ^ (b >>> 13), 3266489909)
29 b = Math.imul(b ^ (b >>> 16), 2246822507) ^ Math.imul(a ^ (a >>> 13), 3266489909)
30
31 return (4294967296 * (2097151 & b) + (a >>> 0)).toString(36)
32}
33
34const FILE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
35
36// The fingerprint of the file a tool call edits, from its input; undefined for any other call.
37export const fileOf = (tool: string, input: Record<string, unknown>): string | undefined => {
38 if (!FILE_TOOLS.has(tool)) return undefined
39 const path = typeof input.file_path === 'string' ? input.file_path : input.notebook_path
40
41 return typeof path === 'string' && path.length > 0 ? fileKey(path) : undefined
42}
43
44// A call that worked and was paid: `lines` is the value the XP took, `file` the fingerprint of the file an edit
45// touched, and `isCommand` says it ran a shell command.
46export type Deed = { lines: number; file?: string; isCommand: boolean }
47
48export const onDeed = (damage: Damage, deed: Deed): Damage => ({
49 files: deed.file === undefined ? damage.files : [...damage.files.filter(key => key !== deed.file), deed.file].slice(-MAX_FILES),
50 lines: damage.lines + Math.max(0, deed.lines),
51 commands: damage.commands + (deed.isCommand ? 1 : 0),
52})
53
54export const damageView = (damage: Damage): DamageView => ({ files: damage.files.length, lines: damage.lines, commands: damage.commands })
55
56const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
57
58// `3 arquivos`, `142 linhas`, `8 comandos`: none of them while there is no damage. `short` gives `3 arq`, `142 lin`,
59// `8 cmd` for a line that does not have the room for the full words.
60export const damageSegments = (damage: DamageView, short = false): string[] =>
61 damage.files === 0 && damage.lines === 0 && damage.commands === 0
62 ? []
63 : short
64 ? [`${damage.files} arq`, `${damage.lines} lin`, `${damage.commands} cmd`]
65 : [plural(damage.files, 'arquivo', 'arquivos'), plural(damage.lines, 'linha', 'linhas'), plural(damage.commands, 'comando', 'comandos')]
66hooks/doing.ts 108 lines1import type { Doing } from '../types'
2import type { Running } from './tracker'
3
4// One verb per kind of tool, in a single table. A tool that is not here is running something.
5export const VERBS: Record<string, string> = {
6 Read: 'lendo',
7 NotebookRead: 'lendo',
8 Grep: 'buscando',
9 Glob: 'buscando',
10 LS: 'buscando',
11 ToolSearch: 'buscando',
12 WebSearch: 'pesquisando na web',
13 WebFetch: 'pesquisando na web',
14 Edit: 'editando',
15 MultiEdit: 'editando',
16 NotebookEdit: 'editando',
17 Write: 'escrevendo',
18 Bash: 'executando',
19 TodoWrite: 'planejando',
20 EnterPlanMode: 'planejando',
21 ExitPlanMode: 'planejando',
22 AskUserQuestion: 'planejando',
23 TaskCreate: 'planejando',
24 TaskUpdate: 'planejando',
25 Agent: 'chamando um aliado',
26 Task: 'chamando um aliado',
27}
28
29const FALLBACK_VERB = 'executando'
30
31export const verbOf = (tool: string): string => VERBS[tool] ?? FALLBACK_VERB
32
33export const MAX_TARGET = 24
34const FILE_FIELDS = ['file_path', 'notebook_path']
35const FILE_TOOLS = new Set(['Read', 'Edit', 'MultiEdit', 'Write', 'NotebookEdit', 'NotebookRead'])
36
37const cut = (text: string): string => (text.length > MAX_TARGET ? `${text.slice(0, MAX_TARGET - 1)}…` : text)
38
39const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
40const PROGRAM_NAME = /^[A-Za-z0-9._+-]{1,30}$/
41// A subcommand (`git commit`, `npm test`): letters and hyphens only. Anything with a digit, `=`, `:`, `/`, `@`,
42// `.`, a leading `-` or a quote can be a value (a token, a password, a URL) and is never shown.
43const SUBCOMMAND = /^[A-Za-z][A-Za-z-]{0,19}$/
44
45// What a shell command shows: the program (no path) and at most a plain subcommand after it. Leading
46// `NAME=value` words, `env` with its assignments and flags, and everything else of the command (flags, values,
47// URLs, credentials) are left out, because the target is drawn on a screen that may be shared. A cut is marked `…`.
48const commandTarget = (command: string): string | undefined => {
49 const words = command.trim().split(/\s+/).filter(Boolean)
50 let at = 0
51 let inEnv = false
52
53 while (at < words.length) {
54 const word = words[at] ?? ''
55 if (ASSIGNMENT.test(word) || (inEnv && word.startsWith('-'))) at += 1
56 else if (word === 'env' && !inEnv) {
57 inEnv = true
58 at += 1
59 } else break
60 }
61
62 // `env` alone (or with only assignments and flags) is the program.
63 const program = (words[at] ?? (inEnv ? 'env' : '')).split('/').pop() ?? ''
64 if (!PROGRAM_NAME.test(program)) return undefined
65
66 const second = words[at + 1]
67 const shown = second !== undefined && SUBCOMMAND.test(second) ? `${program} ${second}` : program
68 const used = words[at] === undefined ? at : at + (shown === program ? 1 : 2)
69 const omitted = used < words.length || (at > 0 && words[at] !== undefined)
70
71 return cut(omitted ? `${shown}…` : shown)
72}
73
74// What the line shows after the verb: the file name without its directory, or the program (and a
75// plain subcommand) of a shell command, cut with an ellipsis. Nothing else is shown, and nothing here is ever saved.
76export const targetOf = (tool: string, input: Record<string, unknown>): string | undefined => {
77 if (FILE_TOOLS.has(tool)) {
78 const path = FILE_FIELDS.map(field => input[field]).find((value): value is string => typeof value === 'string')
79 const name = path?.split(/[\\/]/).filter(Boolean).pop()
80
81 return name === undefined ? undefined : cut(name)
82 }
83
84 if (tool === 'Bash' && typeof input.command === 'string') return commandTarget(input.command)
85
86 return undefined
87}
88
89// The call that counts is the most recent one that has not finished.
90export const doingOf = (running: readonly Running[]): Doing | null => {
91 const call = running.at(-1)
92
93 return call === undefined ? null : { verb: verbOf(call.tool), target: call.target ?? null }
94}
95
96export type LineArgs = { name: string; doing: Doing | null; rest: string; columns: number }
97
98// The line at the top: `Guerreiro: editando register.tsx` when it fits, then without the name,
99// then only the verb. The same three steps serve the resting state.
100export const doingLine = ({ name, doing, rest, columns }: LineArgs): string => {
101 const what = doing === null ? rest : doing.target === null ? doing.verb : `${doing.verb} ${doing.target}`
102 const full = `${name}: ${what}`
103 if (full.length <= columns) return full
104 if (what.length <= columns) return what
105
106 return doing === null ? rest : doing.verb
107}
108hooks/choices.ts 53 lines1// Named choices a person makes with a command (`/guild meters daltonico`) and the plugin keeps between sessions:
2// pure functions to validate a name, read what follows the command word, pick the choice among its sources, list
3// the names and fold the choice into the saved preferences. The meters' color schemes use it; the character palettes
4// can use it the same way, with a set of their own.
5
6export type ChoiceSet<N extends string> = {
7 // The field of the saved preferences that holds the choice.
8 field: string
9 names: readonly N[]
10 fallback: N
11 // The command as typed, for the help and the list: `/guild meters`.
12 command: string
13 // What one name is called, singular: "Esquema". `scope` completes it in the texts: "dos medidores".
14 noun: string
15 scope: string
16}
17
18export type ChoiceRequest<N extends string> = { kind: 'list' } | { kind: 'set'; name: N } | { kind: 'invalid'; typed: string }
19
20export const isChoice = <N extends string>(set: ChoiceSet<N>, value: unknown): value is N => set.names.some(name => name === value)
21
22// What follows the command word: nothing lists, a name (any case, any spaces around) sets, anything else is invalid.
23export const requestOf = <N extends string>(set: ChoiceSet<N>, args: string): ChoiceRequest<N> => {
24 const typed = args.trim().toLowerCase()
25 if (typed === '') return { kind: 'list' }
26
27 return isChoice(set, typed) ? { kind: 'set', name: typed } : { kind: 'invalid', typed }
28}
29
30// The first source that names a choice wins, so the order of `sources` is the order of precedence; none: the fallback.
31export const chooseFrom = <N extends string>(set: ChoiceSet<N>, sources: readonly unknown[]): N => sources.find((source): source is N => isChoice(set, source)) ?? set.fallback
32
33const recordOf = (stored: unknown): Record<string, unknown> | undefined =>
34 typeof stored === 'object' && stored !== null && !Array.isArray(stored) ? (stored as Record<string, unknown>) : undefined
35
36// The choice in the saved preferences, or nothing when it is missing or is not one of the names.
37export const savedChoice = <N extends string>(set: ChoiceSet<N>, stored: unknown): N | undefined => {
38 const value = recordOf(stored)?.[set.field]
39
40 return isChoice(set, value) ? value : undefined
41}
42
43// The saved preferences with `field` set, the other fields kept; what was there before, if not a record, is dropped.
44export const mergeSaved = (stored: unknown, field: string, value: unknown): Record<string, unknown> => ({ ...recordOf(stored), [field]: value })
45
46// "Esquemas dos medidores:" and one name per line, the current one marked.
47export const describeChoices = <N extends string>(set: ChoiceSet<N>, current: N): string =>
48 [`${set.noun}s ${set.scope}:`, ...set.names.map(name => (name === current ? `- ${name} (atual)` : `- ${name}`)), `Troque com ${set.command} NOME`].join('\n')
49
50export const helpText = <N extends string>(set: ChoiceSet<N>, typed: string): string => `${set.noun} "${typed}" não existe. Use: ${set.command} ${set.names.join(' | ')}`
51
52export const savedText = <N extends string>(set: ChoiceSet<N>, name: N): string => `${set.noun} ${set.scope}: ${name} (salvo)`
53hooks/demo.ts 167 lines1import type { Settings } from './guild'
2import { DEFAULTS } from './guild'
3import type { Counters } from './counters'
4import type { Tokens } from './tokens'
5import { addTurn, NO_TOKENS } from './tokens'
6import type { Gold } from './gold'
7import { goldMeasured, goldOperation, NO_GOLD } from './gold'
8import type { Tracker } from './tracker'
9import { callStarted, createTracker, startTurn, toolFinished, toolStarted } from './tracker'
10import type { History } from './forecast'
11import { addSample, EMPTY_HISTORY } from './forecast'
12import type { MeterScheme } from './schemes'
13import type { Usage } from './view'
14import { addXp, emptyGuild } from './xp'
15
16// Scenes for screenshots and a first look: made-up figures, nothing read from
17// the session and nothing saved.
18export const SCENES = ['explorador', 'mago', 'guerreiro', 'bug', 'cansado', 'dormindo'] as const
19
20// One scene per color scheme of the meters, for screenshots: the warrior at work, the scheme forced while the scene
21// runs, and the HP walking through its three levels (see `HP_STEPS`).
22export const SCHEME_SCENES = ['esquema-padrao', 'esquema-contraste', 'esquema-daltonico'] as const
23
24export type Scene = (typeof SCENES)[number] | (typeof SCHEME_SCENES)[number]
25
26const SCHEME_OF: Record<(typeof SCHEME_SCENES)[number], MeterScheme> = { 'esquema-padrao': 'padrao', 'esquema-contraste': 'contraste', 'esquema-daltonico': 'daltonico' }
27
28export const sceneOf = (value: unknown): Scene | undefined => [...SCENES, ...SCHEME_SCENES].find(scene => scene === value)
29
30// The scheme a scene forces on the screen, or nothing for the scenes of the figures.
31export const schemeOfScene = (scene: Scene): MeterScheme | undefined => (scene in SCHEME_OF ? SCHEME_OF[scene as keyof typeof SCHEME_OF] : undefined)
32
33// The percent of the week used in each step of a scheme scene, which makes HP 72, 38 and 15: one level of each color.
34const HP_STEPS = [28, 62, 85] as const
35const HP_STEP_MS = 3_000
36
37export type SceneChoice = { kind: 'scene'; scene: Scene } | { kind: 'off' } | { kind: 'unknown' }
38
39// What `/guild <word>` asks for: a scene, `off` to go back to the real session, or nothing known.
40export const choiceOf = (args: string): SceneChoice => {
41 const word = args.trim().toLowerCase()
42 const scene = sceneOf(word)
43
44 if (scene !== undefined) return { kind: 'scene', scene }
45
46 return word === 'off' ? { kind: 'off' } : { kind: 'unknown' }
47}
48
49const MINUTE_MS = 60_000
50// The demo's clocks: a session open for 1h05, a turn running for 32 s, and a last turn that took 4 min.
51const DEMO_SESSION_MS = 65 * MINUTE_MS
52const DEMO_TURN_MS = 32_000
53const DEMO_LAST_TURN_MS = 4 * MINUTE_MS
54
55// The cost every scene shows, and the one the made-up split adds up to.
56const DEMO_COST_USD = 3.42
57
58const DEMO_COUNTERS: Counters = { combo: 7, best: 9, bugs: 3 }
59
60// Made-up tokens through the code the real ones go through: an earlier turn, the last one (142k of input, 87% from
61// the cache; 1,2M and 91% in all) and a subagent with a low hit rate (38%), so the red shows.
62const demoTokens = (): Tokens => {
63 const usage = (input: number, read: number, written: number, output: number, model: string) => ({
64 input_tokens: input,
65 cache_read_input_tokens: read,
66 cache_creation_input_tokens: written,
67 output_tokens: output,
68 model,
69 })
70 const model = 'claude-sonnet-5-5'
71 const earlier = addTurn(NO_TOKENS, usage(42_000, 968_460, 47_540, 78_000, model), false)
72 const last = addTurn(earlier, usage(6_000, 123_540, 12_460, 18_000, model), false)
73
74 return addTurn(last, usage(12_000, 11_400, 6_600, 4_100, model), true)
75}
76
77// Made-up gold through the code the real one goes through: from $0 to the demo's $3.42, in three readings that
78// each belong to one class (Mage $1.20, Warrior $1.71, Explorer $0.51).
79const demoGold = (): Gold => {
80 const steps = [
81 ['mago', 1.2],
82 ['guerreiro', 2.91],
83 ['explorador', DEMO_COST_USD],
84 ] as const
85
86 return steps.reduce((gold, [cls, usd]) => goldMeasured(goldOperation(gold, cls), usd, cls), goldMeasured(NO_GOLD, 0, 'mago'))
87}
88
89const demoGuild = () => {
90 const levels = [
91 ['mago', 130],
92 ['guerreiro', 420],
93 ['explorador', 55],
94 ] as const
95
96 return levels.reduce((guild, [cls, xp]) => addXp(guild, cls, xp).guild, emptyGuild())
97}
98
99// A call in progress, so the line at the top of the panel has something to say.
100const working = (tracker: Tracker, now: number, tool: string, target?: string): Tracker =>
101 callStarted(toolStarted(startTurn(tracker, { id: 'demo', prompt: '', now: now - DEMO_TURN_MS }), { tool }, now), { id: 1, tool, target })
102
103// The made-up readings behind the forecasts, through the same code the real ones go through: the 5-hour window
104// climbs 5 points every 10 minutes (90 minutes left, the reset in 133: red) and the week 1 point every 3 hours
105// (9 days left, the reset in 2: it lasts).
106const demoReadings = (now: number, usage: Usage): History => {
107 const five = usage.rateLimits.find(limit => limit.kind === 'five_hour')
108 const week = usage.rateLimits.find(limit => limit.kind === 'seven_day')
109 const hours = [40, 45, 50, 55].map((used, i) => ({ at: now - (3 - i) * 10 * MINUTE_MS, used, resetsAt: five?.resetsAt }))
110 const weeks = [26, 27, 28].map((used, i) => ({ at: now - (2 - i) * 180 * MINUTE_MS, used, resetsAt: week?.resetsAt }))
111
112 return {
113 five_hour: hours.reduce((series, sample) => addSample(series, sample, 'five_hour'), EMPTY_HISTORY.five_hour),
114 seven_day: weeks.reduce((series, sample) => addSample(series, sample, 'seven_day'), EMPTY_HISTORY.seven_day),
115 }
116}
117
118const sceneState = (scene: Scene, now: number, settings: Settings): { tracker: Tracker; usage: Usage } => {
119 const base = createTracker(demoGuild(), now)
120 const isSchemeScene = schemeOfScene(scene) !== undefined
121 const weekUsed = isSchemeScene ? (HP_STEPS[Math.floor(now / HP_STEP_MS) % HP_STEPS.length] ?? 28) : 28
122 const usage: Usage = {
123 contextPercent: scene === 'cansado' ? 88 : 36,
124 rateLimits: [
125 { kind: 'seven_day', percentUsed: weekUsed, resetsAt: new Date(now + 2 * 24 * 60 * MINUTE_MS).toISOString() },
126 { kind: 'five_hour', percentUsed: 55, resetsAt: new Date(now + 133 * MINUTE_MS).toISOString() },
127 ],
128 costUsd: DEMO_COST_USD,
129 startedAt: now - DEMO_SESSION_MS,
130 }
131
132 if (scene === 'explorador') return { tracker: working(base, now, 'WebSearch'), usage }
133 if (scene === 'mago') return { tracker: working(base, now, 'TodoWrite'), usage }
134 if (scene === 'guerreiro' || isSchemeScene) return { tracker: working(base, now, 'Edit', 'register.tsx'), usage }
135
136 if (scene === 'bug') {
137 const edit = working(base, now, 'Edit', 'register.tsx')
138 const failed = toolFinished(edit, { use: { tool: 'Bash', command: 'npm test' }, isError: true, now, durationMs: 0 })
139
140 return { tracker: failed.tracker, usage }
141 }
142
143 const resting: Tracker = { ...base, active: 'guerreiro', lastTurnMs: DEMO_LAST_TURN_MS }
144
145 if (scene === 'cansado') return { tracker: resting, usage }
146
147 return { tracker: { ...resting, lastActivityAt: now - settings.sleepAfterMs - 1_000 }, usage }
148}
149
150// The latest payments of XP the demo shows, the newest a moment ago.
151const demoHistory = (now: number): Tracker['recent'] => [
152 { cls: 'explorador', xp: 4, at: now - 9 * MINUTE_MS, tool: 'WebSearch' },
153 { cls: 'explorador', xp: 2, at: now - 7 * MINUTE_MS, tool: 'Read' },
154 { cls: 'mago', xp: 2, at: now - 5 * MINUTE_MS, tool: 'TodoWrite' },
155 { cls: 'guerreiro', xp: 6, at: now - 3 * MINUTE_MS, tool: 'Edit' },
156 { cls: 'guerreiro', xp: 3, at: now - 1 * MINUTE_MS, tool: 'resposta' },
157]
158
159export const demoState = (scene: Scene, now: number, settings: Settings = DEFAULTS): { tracker: Tracker; usage: Usage; history: History } => {
160 const state = sceneState(scene, now, settings)
161
162 // Made-up scores; the bug scene has just lost its combo to the failing call.
163 const counters: Counters = scene === 'bug' ? { ...DEMO_COUNTERS, combo: 0 } : DEMO_COUNTERS
164
165 return { tracker: { ...state.tracker, counters, recent: demoHistory(now), tokens: demoTokens(), gold: demoGold() }, usage: state.usage, history: demoReadings(now, state.usage) }
166}
167hooks/forecast.ts 195 lines1// When a subscription window runs out at the current pace. Pure: it gets the readings of a window
2// (time, percent used) and answers with the time until 100%, or with nothing when the readings are not
3// enough to say. A made-up number is worse than none, so every doubt ends in "nothing".
4//
5// The series lives in memory only (it is never saved, traced or announced) and starts over with the window.
6
7export type WindowKind = 'five_hour' | 'seven_day'
8
9export type Sample = { at: number; used: number }
10
11// The recent readings of one window and the reset time they belong to (null while unknown).
12export type Series = { resetsAt: string | null; samples: Sample[] }
13
14export type History = Record<WindowKind, Series>
15
16export const EMPTY_SERIES: Series = { resetsAt: null, samples: [] }
17
18export const EMPTY_HISTORY: History = { five_hour: EMPTY_SERIES, seven_day: EMPTY_SERIES }
19
20export type Forecast =
21 // Nothing can be said: too few readings, too short a span, too little variation, or readings too old.
22 | { kind: 'none' }
23 // Readings long enough and the percent did not move.
24 | { kind: 'idle' }
25 | { kind: 'exhausted' }
26 // The window outlasts the reset.
27 | { kind: 'lasts' }
28 // The window runs out in `ms` (rounded up to steps of 5 minutes); `urgent` when that comes before the reset.
29 | { kind: 'runs'; ms: number; urgent: boolean }
30
31const MINUTE_MS = 60_000
32const HOUR_MS = 60 * MINUTE_MS
33const DAY_MS = 24 * HOUR_MS
34const STEP_MS = 5 * MINUTE_MS
35
36// The percent moves in whole points, so a pace needs a long stretch and a few points to mean anything.
37export const MIN_SAMPLES = 3
38export const MIN_POINTS = 2
39// A reset time that moves less than this between readings is the same window.
40const SAME_RESET_MS = 5 * MINUTE_MS
41// The last sample may be this many reading gaps old before it says nothing about now.
42const STALE_GAPS = 3
43
44type Spec = {
45 // Only the samples this recent count towards the slope.
46 windowMs: number
47 // An unchanged percent is sampled at most this often; a changed one is always kept.
48 gapMs: number
49 // The samples in the window must cover at least this much time.
50 minSpanMs: number
51 // How long the subscription window itself is: a forecast beyond it, with no reset to compare with, is not shown.
52 lengthMs: number
53}
54
55const SPECS: Record<WindowKind, Spec> = {
56 five_hour: { windowMs: HOUR_MS, gapMs: MINUTE_MS, minSpanMs: 10 * MINUTE_MS, lengthMs: 5 * HOUR_MS },
57 seven_day: { windowMs: 2 * DAY_MS, gapMs: 30 * MINUTE_MS, minSpanMs: 2 * HOUR_MS, lengthMs: 7 * DAY_MS },
58}
59
60const KINDS: readonly WindowKind[] = ['five_hour', 'seven_day']
61
62// Rounds up to the next multiple of 5 minutes; no number for zero, negative or infinite.
63export const roundUp5 = (ms: number): number | null => (Number.isFinite(ms) && ms > 0 ? Math.ceil(ms / STEP_MS) * STEP_MS : null)
64
65const instantOf = (resetsAt: string | null): number | null => {
66 if (resetsAt === null) return null
67
68 const at = Date.parse(resetsAt)
69
70 return Number.isNaN(at) ? null : at
71}
72
73export const sameWindow = (known: string | null, fresh: string | null): boolean => {
74 if (known === null || fresh === null) return known === fresh
75
76 const a = instantOf(known)
77 const b = instantOf(fresh)
78
79 return a === null || b === null ? known === fresh : Math.abs(a - b) < SAME_RESET_MS
80}
81
82export type NewSample = { at: number; used: number; resetsAt?: string }
83
84// Adds a reading. One out of order, repeated or not a percent is ignored; a new reset time or a percent that
85// fell means a new window, so the series starts over from this reading.
86export const addSample = (series: Series, sample: NewSample, kind: WindowKind = 'five_hour'): Series => {
87 const { at, used } = sample
88 if (!Number.isFinite(at) || !Number.isFinite(used) || used < 0 || used > 100) return series
89
90 const resetsAt = sample.resetsAt ?? null
91 const last = series.samples[series.samples.length - 1]
92 const spec = SPECS[kind]
93
94 if (last === undefined) return { resetsAt, samples: [{ at, used }] }
95 if (at <= last.at) return series
96 if (used < last.used || !sameWindow(series.resetsAt, resetsAt)) return { resetsAt, samples: [{ at, used }] }
97 if (used === last.used && at - last.at < spec.gapMs) return series
98
99 return { resetsAt: series.resetsAt, samples: [...series.samples.filter(known => known.at >= at - spec.windowMs), { at, used }] }
100}
101
102// The readings the session just took: a window the reading does not carry is left alone, so a figure the
103// engine did not report again is never counted as one more reading of "nothing changed".
104export const recordReadings = (
105 history: History,
106 readings: ReadonlyArray<{ kind: string; percentUsed: number; resetsAt?: string }>,
107 now: number,
108): History => {
109 const next: History = { ...history }
110
111 for (const kind of KINDS) {
112 const reading = readings.find(item => item.kind === kind)
113 if (reading !== undefined) next[kind] = addSample(history[kind], { at: now, used: reading.percentUsed, resetsAt: reading.resetsAt }, kind)
114 }
115
116 return next
117}
118
119const isUsable = (samples: readonly Sample[], now: number): boolean =>
120 samples.every(
121 (sample, i) =>
122 Number.isFinite(sample.at) &&
123 Number.isFinite(sample.used) &&
124 sample.used >= 0 &&
125 sample.used <= 100 &&
126 sample.at <= now &&
127 (i === 0 || (sample.at > (samples[i - 1]?.at ?? 0) && sample.used >= (samples[i - 1]?.used ?? 0))),
128 )
129
130export const forecastOf = (series: Series, now: number, kind: WindowKind): Forecast => {
131 const none: Forecast = { kind: 'none' }
132 const spec = SPECS[kind]
133
134 if (!Number.isFinite(now) || !isUsable(series.samples, now)) return none
135
136 const recent = series.samples.filter(sample => sample.at >= now - spec.windowMs)
137 const first = recent[0]
138 const last = recent[recent.length - 1]
139 if (first === undefined || last === undefined || now - last.at > spec.gapMs * STALE_GAPS) return none
140 if (last.used >= 100) return { kind: 'exhausted' }
141
142 const span = last.at - first.at
143 if (recent.length < MIN_SAMPLES || span < spec.minSpanMs) return none
144
145 const points = last.used - first.used
146 if (points === 0) return { kind: 'idle' }
147 if (points < MIN_POINTS) return none
148
149 // Multiplied before divided, so a pace that lands on a whole number of minutes does so exactly.
150 const left = Math.max(0, (((100 - last.used) * span) / points) - (now - last.at))
151 const ms = roundUp5(left) ?? STEP_MS
152 const reset = instantOf(series.resetsAt)
153 // Without a reset to compare with, a forecast longer than the window itself is a pace too slow to mean anything.
154 if (reset === null || reset <= now) return ms > spec.lengthMs ? none : { kind: 'runs', ms, urgent: false }
155
156 // The comparison is made with the value the screen shows (rounded up), so the color and the text agree.
157 return ms >= reset - now ? { kind: 'lasts' } : { kind: 'runs', ms, urgent: true }
158}
159
160const short = (ms: number): string => {
161 const minutes = ms / MINUTE_MS
162 if (minutes < 60) return `${minutes}min`
163
164 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
165}
166
167// "esgota em ~40min", "esgota em ~2h05", "esgota em ~3 dias" (only the 7-day window), "dura até o reset",
168// "sem consumo" or "esgotado"; null when there is nothing to say. `shorter` drops the "em" of a narrow place.
169export const forecastText = (forecast: Forecast, kind: WindowKind, shorter = false): string | null => {
170 if (forecast.kind === 'idle') return 'sem consumo'
171 if (forecast.kind === 'exhausted') return 'esgotado'
172 if (forecast.kind === 'lasts') return 'dura até o reset'
173 if (forecast.kind === 'none') return null
174
175 const ms = roundUp5(forecast.ms)
176 if (ms === null) return null
177 if (kind === 'seven_day' && ms >= DAY_MS) {
178 const days = Math.ceil(ms / DAY_MS)
179
180 return `esgota ${shorter ? '' : 'em '}~${days} ${days === 1 ? 'dia' : 'dias'}`
181 }
182
183 return `esgota ${shorter ? '' : 'em '}~${short(ms)}`
184}
185
186// What the screen shows for a window: the text and whether it is red; null when there is nothing to show.
187export type ForecastLine = { text: string; short: string; urgent: boolean }
188
189export const forecastLine = (series: Series, now: number, kind: WindowKind): ForecastLine | null => {
190 const forecast = forecastOf(series, now, kind)
191 const text = forecastText(forecast, kind)
192
193 return text === null ? null : { text, short: forecastText(forecast, kind, true) ?? text, urgent: forecast.kind === 'runs' && forecast.urgent }
194}
195hooks/guild.ts 111 lines1import type { TaskClass } from './classify'
2
3export type Animation = 'trabalho' | 'dormindo' | 'cansado' | 'parado'
4
5export type Settings = {
6 // Idle time after which characters fall asleep.
7 sleepAfterMs: number
8 // Context fill, in percent, from which characters tire.
9 tiredAtPercent: number
10}
11
12export const DEFAULTS: Settings = { sleepAfterMs: 90_000, tiredAtPercent: 75 }
13
14export type Snapshot = {
15 // A turn of the main loop is running.
16 working: boolean
17 // The class doing that turn's work, or the last one that did.
18 active: TaskClass
19 // Time since the last prompt, tool call or turn end.
20 idleMs: number
21 // How full the context window is; unknown until the first response.
22 contextPercent: number | undefined
23}
24
25// Working beats everything and only the active class works; a rested
26// character after a long idle sleeps; one in a heavy context is tired;
27// the rest wait.
28export const animationFor = (cls: TaskClass, snapshot: Snapshot, settings: Settings = DEFAULTS): Animation => {
29 if (snapshot.working && cls === snapshot.active) return 'trabalho'
30 if (!snapshot.working && snapshot.idleMs >= settings.sleepAfterMs) return 'dormindo'
31 if ((snapshot.contextPercent ?? 0) >= settings.tiredAtPercent) return 'cansado'
32
33 return 'parado'
34}
35
36export type BugPhase = 'none' | 'alive' | 'defeated'
37
38// The enemy of the warrior: it shows up when a tool fails and goes down when
39// the same kind of call succeeds, or on its own after a quiet period.
40export type Bug = { phase: BugPhase; category: string; since: number }
41
42export const NO_BUG: Bug = { phase: 'none', category: '', since: 0 }
43
44// How long the defeat animation stays before the bug is gone.
45export const DEFEAT_MS = 1_200
46
47// How long a live bug stays without a new tool error before it is gone. A new
48// error restarts the count, since it stamps the bug anew.
49export const EXPIRE_MS = 120_000
50
51const RUNNERS_WITH_SUBCOMMAND = /^[a-z][\w.-]*$/
52
53// What makes two tool calls the same kind: the tool, and for Bash the program
54// and its subcommand (`npm test`, `git push`), so a passing `npm test` beats a
55// failing `npm test` but not a failing `git push`.
56export const categoryOf = (tool: string, command?: string): string => {
57 if (tool !== 'Bash') return tool
58
59 const [head = '', second = ''] = (command ?? '').trim().split(/\s+/)
60 const sub = RUNNERS_WITH_SUBCOMMAND.test(second) ? ` ${second}` : ''
61
62 return `Bash:${head}${sub}`
63}
64
65export type ToolResult = { category: string; isError: boolean; now: number }
66
67export const onToolResult = (bug: Bug, result: ToolResult): Bug => {
68 if (result.isError) return { phase: 'alive', category: result.category, since: result.now }
69 if (bug.phase === 'alive' && bug.category === result.category) {
70 return { phase: 'defeated', category: bug.category, since: result.now }
71 }
72
73 return bug
74}
75
76// The bug a clock tick sees: a defeated one is gone once its animation ran, and
77// a live one once the quiet period passed since its last error.
78export const bugAt = (bug: Bug, now: number): Bug => {
79 if (bug.phase === 'defeated' && now - bug.since >= DEFEAT_MS) return NO_BUG
80 if (bug.phase === 'alive' && now - bug.since >= EXPIRE_MS) return NO_BUG
81
82 return bug
83}
84
85// The tools that only ask the person something (approve a plan, answer a question): an error from
86// them is never work that failed, only the person saying no or walking away.
87const INTERACTION_TOOLS: readonly string[] = ['ExitPlanMode', 'AskUserQuestion']
88
89// How the engine words a refusal in the error text the model reads. The result has no field that
90// tells a refusal from a failure (`isError` is the same for both), so the start of the text is the
91// only hint. Only the start counts: a failing command begins with `Exit code N`, so output that
92// merely mentions "rejected" (a `git push`) is never taken for a refusal.
93const REFUSAL_TEXTS: readonly string[] = [
94 'Permission for this tool use was denied',
95 "The user doesn't want to proceed with this tool use",
96 '[Request interrupted by user for tool use]',
97]
98
99export type ToolOutcome = { tool: string; isError: boolean; text?: string }
100
101// A call the person turned down (declined plan, denied permission, interrupted question) is not a
102// failure and spawns no bug.
103export const isRejection = (outcome: ToolOutcome): boolean => {
104 if (!outcome.isError) return false
105 if (INTERACTION_TOOLS.includes(outcome.tool)) return true
106
107 const text = (outcome.text ?? '').trimStart()
108
109 return REFUSAL_TEXTS.some(refusal => text.startsWith(refusal))
110}
111hooks/layout.ts 52 lines1import type { BugPhase } from '../types'
2
3export type Size = 'full' | 'mini'
4
5// A character of 32x32 pixels takes 32 columns and 16 rows; the reduced one half of each.
6export const CELLS: Record<Size, { columns: number; rows: number }> = {
7 full: { columns: 32, rows: 16 },
8 mini: { columns: 16, rows: 8 },
9}
10
11export const INFO_COLUMNS = 28
12const GAP = 1
13
14export type Layout = {
15 active: Size
16 info: boolean
17 others: boolean
18 // The bug beside the characters, or under the stats of the active one when it has the room.
19 bug: boolean
20 bugBelow: boolean
21}
22
23// What fits in the band: the active character first, then the text beside it,
24// then the other two characters, then the bug. Nothing at all when even the
25// active one does not fit.
26export const chooseLayout = (
27 columns: number,
28 rows: number,
29 bandSize: 'full' | 'compact',
30 bug: BugPhase,
31): Layout | null => {
32 const active: Size = bandSize === 'full' && rows >= CELLS.full.rows ? 'full' : 'mini'
33 const own = CELLS[active].columns
34 if (rows < CELLS[active].rows || columns < own) return null
35
36 const withInfo = own + GAP + INFO_COLUMNS
37 const withOthers = withInfo + GAP + CELLS.mini.columns * 2 + GAP
38 const withBug = withOthers + GAP + CELLS.mini.columns
39
40 const info = columns >= withInfo
41 // A full character is 16 rows tall: eight lines of stats and the bug's eight fit beside it.
42 const bugBelow = bug !== 'none' && info && active === 'full'
43
44 return {
45 active,
46 info,
47 others: columns >= withOthers,
48 bug: bug !== 'none' && !bugBelow && columns >= withBug,
49 bugBelow,
50 }
51}
52hooks/sidebar.ts 36 lines1import type { BugPhase } from '../types'
2import type { Size } from './layout'
3import { CELLS } from './layout'
4
5export type SidebarLayout = {
6 // The active character's size, or null when only text fits.
7 active: Size | null
8 // The stats to the right of the active character, or under it when there is no width.
9 stats: 'beside' | 'below'
10 // The other two characters side by side, one over the other, or not at all.
11 others: 'row' | 'stack' | null
12 bug: boolean
13}
14
15const GAP = 1
16// The widest stat line (`MP ▰▰▰▰▰▰▰▰ 100% 2h13`) with a little to spare.
17export const STATS_COLUMNS = 24
18const SIDE_BY_SIDE = CELLS.mini.columns * 2 + GAP
19
20// What fits in a narrow column that the person sizes: the active character as
21// large as the width lets it, its stats to the right of it when they fit, then
22// the bug under them and the others.
23// Below the width of a reduced character only text is left.
24export const chooseSidebarLayout = (columns: number, bandSize: 'full' | 'compact', bug: BugPhase): SidebarLayout => {
25 if (columns < CELLS.mini.columns) return { active: null, stats: 'below', others: null, bug: false }
26
27 const active: Size = bandSize === 'full' && columns >= CELLS.full.columns ? 'full' : 'mini'
28
29 return {
30 active,
31 stats: columns >= CELLS[active].columns + GAP + STATS_COLUMNS ? 'beside' : 'below',
32 others: columns >= SIDE_BY_SIDE ? 'row' : 'stack',
33 bug: bug !== 'none',
34 }
35}
36