SLOPSHOPPER

guild-monitor

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

panebandspinnerguardcommandtoast
v0.6.0MITupdated 2026-10-21viniciusilveira/guild-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · guild-monitor
│ ┃ Guilda ✕ › fix the failing auth test and add an audit log call │ ┃ Explorador: de prontidão │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ▄▄██████▄▄ ⏺ Update(src/auth.ts) │ ┃ ██████████ ⎿ Added 2 lines, removed 1 line │ ┃ ██████████ ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ ██▀▀██████████▀▀████ │ ┃ █████▀▀██████████▀▀████ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /guild │ ┃ ⎿ guild-monitor: Use /guild panel | bar | trace [N|clear] | meters │ ┃ │ ┃ │ ┃ │ ┃ Explorador Nv.1 opus-5-5 │ ┃ XP ▱▱▱▱▱▱▱▱ 0/40 │ ┃ HP ▱▱▱▱▱▱▱▱ -- │ ┃ MP ▰▰▰▰▰▰▱▱ 69% │ ┃ ST ▰▰▰▰▱▱▱▱ 51% │ ┃ ouro $0.42 │ ┃ HP=7d MP=5h ST=ctx │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Guilda
Explorador: de prontidão ▄▄██████▄▄ ██████████ ██████████ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ██▀▀██████████▀▀████ █████▀▀██████████▀▀████ Explorador Nv.1 opus-5-5 XP ▱▱▱▱▱▱▱▱ 0/40 HP ▱▱▱▱▱▱▱▱ -- MP ▰▰▰▰▰▰▱▱ 69% ST ▰▰▰▰▱▱▱▱ 51% ouro $0.42 HP=7d MP=5h ST=ctx ▄▄ ▀▀▄▀▀█▀▄ ▀▀▀▀▀█▀▀ ▀ ▀▀▀ ▀ ██ ▄▄▄ ████ ██▀██████ ▄ ▄▄▄▀▀▀▀▄▄▄▄█▄ █████████ █ █▀█████▀██▀▀ ██▀▀▀▀▀▀▀██▄▀▄ ▀██████████▀▀ ███▀▀▀▀▀▀▀█▀▀ ██████████ █ ███████████ ▀▀▀▀▀▀▀▀▀▀ █ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀ ▀▀ ▀▀ Mago Nv.1 Guerreiro Nv.1 combo x3 · melhor x5 · bugs 0 sessão 30min turno -- tokens input 97,4k · output 1,5k · cache 93% sessão input 97,4k · output 1,5k · cache 93% Últimas operações nenhuma ainda
README

Guild Monitor

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">

What it does

  • Three characters, one per kind of work. The one on stage follows what the agent is doing, not what you typed.
  • Animated in the terminal. Full-size sprites (32×16 cells) drawn with half-block characters and truecolor, with no image protocol needed. Works in iTerm2, foot and inside tmux.
  • Meters for the 7-day limit, the 5-hour limit, the context window and the session cost.
  • XP and levels per class, paid per operation and kept between sessions.
  • A bug that shows up every time a tool call fails, is defeated when it works again and leaves on its own after 2 minutes without a new error.

The guild

CharacterDoesComes on stage when the agent…
Mageplansis in plan mode, or organises the work (TodoWrite, AskUserQuestion, plan tasks, Plan subagents)
Warriorexecutes and fixeschanges things: Edit, Write, MultiEdit, shell commands that are not read-only, other subagents
Explorer (default)researches and investigatesreads 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.

States

StateWhenAnimation
workinga turn is runningthe active character works
tiredcontext ≥ 75% (configurable)everyone looks worn out
asleepidle for 90 s (configurable)everyone sleeps
idleotherwiseeveryone 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>

Meters

MeterShowsColor
HPwhat is left of the 7-day usage windowgreen above 50%, yellow 25–50%, red below 25%
MPwhat is left of the 5-hour window, with the time until it resetsblue
ST (stamina)what is left of the context windoworange
Goldwhat the session has cost, in USDgold
XPprogress to the next level of the active characterpurple

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.

Low alerts

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.

  • Once per cycle. Each band warns once. It warns again only after the value goes back above it by 5 points (25% for the 20% band, 15% for the 10% band), when the window resets, or, for the stamina, after a compaction that recovered it (the old reading right after compacting does not warn again). A drop past both bands at once shows only the 10% one.
  • No figure, no alert. A meter the engine does not report (HP and MP outside a subscription) neither warns nor re-arms; the stamina alert works without a subscription.
  • Memory only. What was already warned lives in memory and is gone with the session, so a restart may warn once more. Nothing is saved to the store or the /guild trace log, and no toast carries a path, prompt or command.
  • Demo scenes. They never warn.
  • The alerts option. on (default) or off. With off the guild shows no toast at all, the rest toast of the compactions included.

Forecast of the windows

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.

  • It is estimated from the readings of the rate limits the plugin already takes, with the average slope of the recent ones (the last hour for the 5-hour window, the last two days for the week). The percent moves in whole points, so it only says something with at least 3 readings, over 10 minutes (2 hours for the week) and a variation of 2 points. Before that nothing is shown: the forecast never invents a number. The time is rounded up in steps of 5 minutes; the week shows days.
  • When a window resets (a new reset time, or the percent drops) the readings start over, and the forecast comes back once there are enough new ones. A window the session stops reporting stops being forecast.
  • In the side panel each window has its own line under the legend. In the bottom band there is room for one: the window that runs out before its reset, otherwise the 5-hour one. In the compact band (8 rows) it takes the line of the legend and gives way to the 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.
  • Only the recent readings exist, in memory: nothing is saved to the store, to the /guild trace log or shown in a toast. The option showForecast set to off hides it all.
  • The demo scenes show a made-up forecast: 5h esgota em ~1h30 in red and 7d dura até o reset.

XP and levels

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)
  • Time is how long the call ran. Lines are what an edit or a new file wrote.
  • Defeating a bug pays +10 to the class that made the passing call.
  • A turn that ends with an answer pays +3 to the active class.
  • Interrupted turns keep what was already paid.

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.

The bug

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.

What the character is doing now

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.

  • The line follows the tool calls: it appears when a call starts and goes away when it ends, whether it succeeded, failed or was refused by a permission or a hook. With several calls in parallel, the most recent one that has not finished wins. A call that waits for your permission shows as in progress until you answer, and goes away once it is refused: the engine's permission check runs after this mod's hook and the answer to the dialog is not observable, so a refused call cannot be told apart beforehand.
  • One verb per kind of tool: 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).
  • The target is only the file name, without the directory, or, for a shell command, only the program and a plain subcommand (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.
  • When nothing runs, or the turn ends, the line shows the resting state: de prontidão, cansado or dormindo.
  • In a narrow panel the line gives up the name, then the target, and ends as the verb alone.
  • The demo scenes 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.

Session and turn timers

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.

  • Session timer. It counts from the session's real start, which the engine reports: a resumed session counts from its first launch, and /clear starts it over (the new start is picked up within 2 seconds). It reads -- while the start is unknown.
  • Turn timer. It runs from your prompt until the answer, then stops and keeps the final duration, the one the engine reports when the turn completes (not a local subtraction), until the next turn begins. It reads -- before the first turn. An interrupted or failed turn keeps its duration too.
  • Bottom band. It shows only the turn timer, on the gold line (ouro $1.50 · turno 32s), and only while a turn runs, so the compact band's 8 lines still hold.
  • Redraws. The timers are kept in whole seconds, so the view changes (and the panel redraws) once per second, not at every animation frame. Nothing about them is saved to the store, the /guild trace log or a toast.
  • Demo scenes. All of them show both timers: 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.

Combo and defeated bugs

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.

  • Combo. It grows with every operation that finishes without error and resets on a failure; the failure still raises the bug as before. Calls you or a hook refused, and calls made by subagents, are ignored: they neither raise nor reset it (the same rule as the XP).
  • Hidden below 3. The combo and the best combo only show from 3 on, so the panel is not filled with x1. Before that the line is just bugs 0.
  • Bugs defeated. Each defeat counts once: the moment a live bug goes down, the same one that pays the XP bonus.
  • Damage dealt. After the bugs the line shows the material result of the work, for example 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.
  • Compactions. Each time the main agent compacts the context (your /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.
  • Rest toast. Compacting recovers stamina, so the guild announces it: 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.
  • Memory only. Nothing is saved to the store or the /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.
  • Bottom band. It does not show the counters, so the compact band's 8 lines still hold.
  • Demo scenes. All of them show made-up values, combo x7 · melhor x9 · bugs 3; the bug scene has just lost its combo and shows melhor x9 · bugs 3.

Gold by class (estimate)

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.

  • The rule. The change in cost between two readings goes to the class of the operations that finished in that interval. If more than one class worked in it, the change is split in proportion to each class's number of operations; with no operation (the model only answered) it goes to the active class. The first reading is only the starting point: it attributes nothing, so on a resumed session the split adds up to what was spent since the plugin began to watch.
  • Always adds up. The three amounts add up exactly to the change in cost since that starting point (the money is counted in whole micro-dollars, so there is no floating point drift). The percentages add up to 100: each is rounded down and the rest goes to the biggest fractions. The amounts are shown rounded to cents.
  • Resumed sessions. The first reading is only the starting point, so on a resumed session the three lines add up to less than the 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).
  • Odd readings. A cost that did not change attributes nothing. A cost that drops (a /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.
  • Operations. Every tool call of the main agent that finishes inside a turn counts, whether it worked or failed. Calls you or a hook refused do not. Calls made by subagents are ignored (the same guard as the XP and the combo, 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.
  • No lines before a change. Until the cost has changed once, the lines are not shown.
  • Narrow panel. The three lines always use the same form, the longest that fits: 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.
  • Session only. Nothing is saved to the store, the /guild trace log or a toast, and everything starts over with the session. A history by class is a separate feature.
  • Bottom band. It does not show the split, so the compact band's 8 lines still hold.
  • Demo scenes. All of them show a made-up split that adds up to the demo's $3.42: Mago ~$1.20 (35%), Guerreiro ~$1.71 (50%) and Explorador ~$0.51 (15%).

Tokens and cache hit rate

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.

  • Three lines. 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).
  • The numbers. They come from the end of each turn: uncached input, output, cache read and cache written. The 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 --.
  • Abbreviation. One formatter abbreviates with a decimal comma: 999, 12,4k, 142k, 1,2M.
  • Low rate. Under 50% the line turns red; 50% or more uses the normal color.
  • Turns without usage. An interrupted turn or one ended by an API error carries no usage and adds nothing; the lines stay as they were (and do not show before the first turn with usage).
  • Model. The model of the last main-agent turn shows dim beside the active character's name (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.
  • Narrow panel. Each line takes the longest form that fits: 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.
  • Bottom band. It does not show the token lines (the compact band's 8 lines still hold); it shows only the dim model beside the name.
  • Memory only. Nothing is saved to the store, the /guild trace log or a toast, and everything starts over with the session.
  • Demo scenes. All of them show made-up values: 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.

Color schemes of the meters

The meters and the text lines can use one of three color schemes, independent of the characters' colors:

SchemeWhat it is
padraothe colors the monitor always had (HP green, yellow, red; MP blue; ST orange; XP purple; gold yellow; unknown gray)
contrastehigh contrast: bright colors with at least 7:1 against black (WCAG AAA)
daltonicodoes 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
  • Option and command. The 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.
  • Where it applies. The band, the side panel and the demo scenes: meters, the forecast and token lines, the counters and the bug's warning. The class colors (name, what the character is doing, gold by class, latest operations) and the sprites do not change.
  • The unknown gray. The -- of a figure nobody reported keeps a gray that stays distinguishable in every scheme.
  • Demo scenes. 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.
  • Privacy. Only the name of the scheme is saved, with the panel's open/closed state, in the interface preferences; never session data.
  • Accessibility note. The 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.

Side panel

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:

  • the active character at full size, with its stats beside it, and the bug right under the stats when a tool call fails;
  • the other two characters under them, and a list of the latest XP payments (3min Warrior +6 Edit);
  • t
Source 24 files
hooks/register.tsx 589 lines
1import { 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}
589
hooks/classify.ts 211 lines
1export 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}
211
hooks/alerts.ts 85 lines
1import { 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}
85
hooks/compactions.ts 55 lines
1// 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'}`]
55
hooks/damage.ts 66 lines
1// 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')]
66
hooks/doing.ts 108 lines
1import 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}
108
hooks/choices.ts 53 lines
1// 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)`
53
hooks/demo.ts 167 lines
1import 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}
167
hooks/forecast.ts 195 lines
1// 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}
195
hooks/guild.ts 111 lines
1import 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}
111
hooks/layout.ts 52 lines
1import 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}
52
hooks/sidebar.ts 36 lines
1import 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