BIG BEAR DEV ORCHESTRATOR — Code Smarter. Build Faster. Spend Less. Smart model routing, task command pane, token intelligence, usage band, code/git guardians…

<img src="assets/readme/banner.png" alt="BIG BEAR DEV ORCHESTRATOR: Code Smarter. Build Faster. Spend Less." width="100%">
Pick the right Claude model for every request. See every task, token and limit in one pane. Spend less.
Install · Features · Commands · How routing works · Português
BIG BEAR DEV ORCHESTRATOR is a mod for Claude Code. It sits beside your session and does five jobs:
It is one plugin with independent modules. Turn any of them off; the rest keep working.
| Idle | Researching | Working |
|---|---|---|
| <img src="assets/readme/bear-idle.png" width="150" alt="Big Bear, idle"> | <img src="assets/readme/bear-research.png" width="150" alt="Big Bear, researching"> | <img src="assets/readme/bear-work.png" width="150" alt="Big Bear, working"> |
The pane header changes with what the agent is doing: reading and searching, or changing things.
╭──────────────────────────────────────────────────────────╮
│ [bear] BIG BEAR DEV ● Working │
│ my-project ⎇ main │
│ [ BALANCED ] [ SONNET · THE BUILDER ] │
╰──────────────────────────────────────────────────────────╯
▍TASKS ─────────────────────────────────────────── 4/6 · 67%
[ 1 RUNNING ] [ 3 DONE ]
[ WORKING FOR 2m31s ] [ WORKED 14m08s ] [ LEFT ~3m40s ]
▌● S Fix · Mammonite damage on miss ▰▰▰▱▱ 2m31s ETA ~1m50s
✓ H Search · where is calcularDano? ▰▰▰▰▰ 38s 9k tok
✓ O Audit · race condition in drops ▰▰▰▰▰ 6m02s 212k tok
▍TOKEN INTELLIGENCE ───────────────────────────────────────
╭ TOKENS ───────────╮ ╭ API EQUIVALENT ───╮
│ session 1.24M │ │ total $4.18 │
╰───────────────────╯ ╰───────────────────╯
╭ SMART SAVINGS ────╮ ╭ MODEL USAGE ──────╮
│ est. −38% │ │ HAIKU ██░░░ 24% │
│ tokens ~470k │ │ SONNET █████ 61% │
╰───────────────────╯ ╰───────────────────╯
▍GUARDIANS ────────────────────────────────────────────────
● Git 13 changes ● QA passed
● Security clear ● Context 34%
Above your prompt there is also a one-line status band: mode, active model, 5H and WEEK meters, reset time and context fill.
The pane fits the height of your window: with room it shows full cards, without it switches to a dense layout. It never needs a scrollbar.
| Module | What it really does |
|---|---|
| Smart Brain | Classifies the prompt with local rules and rewrites the model of each request at turn.step. Haiku reads and searches, Sonnet builds, Opus plans and audits. Keeps the model steady through a turn, avoids switching while the prompt cache is warm, escalates one tier (at most once per turn) when tools keep failing, and learns from your history. |
| Intelligence modes | ECO (cheapest that works), BALANCED (default), GOD (most capable where it matters; it is not "Opus for everything"). |
| Task Command | Live pane: every task with status, a verified 5-step progress bar, elapsed time, ETA from history, tokens, errors, and a tree of the subagents that actually exist. |
| Token Intelligence | Input, output, cache read and cache write tokens per task and per session; API-equivalent cost from editable prices; estimated savings vs. a reference model; usage per model. |
| Usage Monitor | 5-hour and weekly windows with reset time, read from $.session.usage(). Alerts at 70, 85 and 95 %. Never guesses a weekly figure from session tokens. |
| Code Guardian | Asks before force push, branch deletion, reset --hard, rm -rf, DROP TABLE, production deploys. Blocks irreversible disk operations. Flags secrets being written and critical files being changed. |
| Git Guardian | Branch, ahead/behind, changes, conflicts, merge state, last commit. Warns on pushes to protected branches and commits with failing QA. |
| Auto QA | Finds typecheck, lint, test and build in your package.json and runs them for real. Project profiles included. |
| Bug Hunter | /bigbear bug <error>: a guided investigation that starts on Sonnet and escalates to Opus for architectural causes. |
| Context Manager | Warns as the context window fills. Never compacts on its own. Keeps technical decisions you record. |
| Dev Analytics | Local history: success rate by task type, average time, model mix, cost. Feeds the ETA and the routing. |
| Showcase | A clean panel for screenshots, plus a text summary with paths and secrets stripped. |
Also: a notification when each task finishes, readable task titles (a kind plus a clean summary, never raw markup), and a full settings panel.
Heads up: Claude Code mods are an early-access feature. Tested on Claude Code 2.1.284 and 2.1.293. The API may change between releases.
1. Turn on function hooks. Add this to ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1"
}
}
2. Add the marketplace and install (user scope, so it loads in every project):
claude plugin marketplace add https://github.com/jonathasmoraes01/big-bear-dev-orchestrator.git
claude plugin install big-bear@big-bear-dev --scope user
Use the full HTTPS URL: the
owner/reposhorthand tries SSH first and fails on machines that have never connected to GitHub over SSH.
3. Start a new session. The pane opens by itself and the usage band appears above the prompt. Check everything with:
/bigbear doctor
To confirm which models your account can use: /bigbear doctor probe (one tiny call per model).
Update: claude plugin update big-bear@big-bear-dev, then /reload-plugins. Uninstall: claude plugin uninstall big-bear@big-bear-dev --scope user.
Works in the Claude Code CLI, the VS Code integrated terminal and the Claude desktop app (Code tab). In the VS Code extension chat the pane is not shown (the host does not draw mod panes there); the /bigbear commands still work as text.
| Command | Effect | |||
|---|---|---|---|---|
/bigbear | Open or close the pane | |||
/bigbear status | Overall state | |||
| `/bigbear mode eco\ | balanced\ | god` | Switch mode (add --project to keep it for this project only) | |
| `/bigbear model auto\ | haiku\ | sonnet\ | opus` | Pin a model, or go back to automatic |
/bigbear block <tier> · unblock <tier> | Forbid or allow a model | |||
/bigbear off · on | Turn routing off or on. Off means stock Claude Code behavior | |||
/bigbear tasks · usage · models · history · summary | Inspect | |||
| `/bigbear qa [profile\ | list]` | Run your project's real checks | ||
/bigbear bug <error> | Bug Hunter | |||
/bigbear decision <text> | Record a technical decision | |||
/bigbear settings | Settings panel | |||
/bigbear showcase [export] | Screenshot panel / shareable text summary | |||
/bigbear doctor [probe] | Diagnose your install | |||
/bigbear set-model <tier> <id> | Change the model id behind a tier | |||
/bigbear alerts 70 85 95 | Usage alert thresholds | |||
/bigbear price <tier> <in> <out> <read> <write> | Update prices (USD per million tokens) | |||
/bigbear reset · project reset | Restore defaults |
In the pane: 1 ECO, 2 BALANCED, 3 GOD, a auto on/off, s settings, w showcase (focus the pane first with ctrl+x tab).
prompt ─▶ local classifier ─▶ mode policy ─▶ guardrails ─▶ model for this request
(no model call) ECO/BAL/GOD blocked tiers,
manual pin,
warm cache,
failure history
| 🟢 HAIKU · Scout | 🔵 SONNET · Builder | 🟣 OPUS · Architect | |
|---|---|---|---|
| ECO | search, read, docs, small edits | features, bugs, tests | only critical and complex |
| BALANCED | search, read, simple edits | features, bugs, refactors, tests | architecture, audits, critical |
| GOD | trivial chat | simple builds | anything complex or risky |
claude-haiku-5-5, claude-sonnet-5-5 and claude-opus-5-5; BIG BEAR never invents ids. Use /bigbear doctor probe to check them against your account.BIG BEAR does not make up metrics.
N/D — indisponível./bigbear price.The interface text is currently in Portuguese (pt-BR), the author's language. The commands, settings and docs are in English.
Auto QA reads your package.json (typecheck, lint, test, build) and offers the profiles quick, standard and full. A profile for the author's own game project, Ragnarok Classic Idle (combat, idle bot, party, frontend), ships as an example of a per-project profile: see hooks/src/config/project-profiles/ragnarok.ts. Add yours next to it.
Read docs/ARCHITECTURE.md. In short: hooks/register.tsx is the only layer that captures events and holds the engine handle ($); every module receives a small Io object and keeps its rules in pure, tested functions.
hooks/register.tsx events, state, wiring
hooks/src/core/ classifier · router · tasks · orchestrator · titles
hooks/src/modules/ guardian · git · qa · monitor
hooks/src/services/ pricing · analytics · persistence · io
hooks/src/ui/ pane · band · settings · showcase · avatar
types/index.d.ts state contract
tests/ unit + in-engine integration
git clone https://github.com/jonathasmoraes01/big-bear-dev-orchestrator
cd big-bear-dev-orchestrator
npm install
npm run typecheck
claude plugin validate .claude-plugin/plugin.json
claude plugin test .
Run a local copy in a session: claude --plugin-dir . (with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1). Rebuild the pixel-art avatars with python tools/build-avatars.py and this banner with python tools/build-banner.py (needs Pillow).
Test results and what was verified in real sessions: docs/TEST-RESULTS.md.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1./model for each request. Use /bigbear off or /bigbear model <tier> to take control back.WAITING means a tool is running; the mod cannot see whether a tool waits for your approval.export produces text.Issues and pull requests are welcome. Please run the three commands under Development before opening a PR, and keep new rules in pure functions with a test. Model ids, prices and the Guardian rules are the places most likely to need updates as Claude Code and the models evolve.
MIT © 2026 jonathasmoraes01. The bear artwork in assets/ is included for use with this project.
🐻 Code Smarter. Build Faster. Spend Less.
hooks/register.tsx 267 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { COMMAND, runCommand } from './src/commands'
5import {
6 afterSpawn,
7 afterStep,
8 afterTool,
9 beforeSpawn,
10 beforeStep,
11 beforeTool,
12 onPromptSubmit,
13 onTurnComplete,
14 onTurnStart,
15} from './src/core/orchestrator'
16import type { BigBearQa, BigBearTask } from '../types'
17import { DEFAULT_CONFIG } from './src/config/defaults'
18import { runtime } from './src/core/runtime'
19import { isOpen } from './src/core/tasks'
20import { guardEdit, guardShell, shellFallback } from './src/modules/guard-hooks'
21import type { EditInput } from './src/modules/guard-hooks'
22import { onMeasure } from './src/modules/monitor'
23import { loadHistory, loadSettings, refreshGit, usageSnapshot } from './src/services/engine'
24import type { Io } from './src/services/io'
25import { emptyMetrics } from './src/services/pricing'
26import type { ApiUsage } from './src/services/pricing'
27import { renderBand } from './src/ui/band'
28import { kitOf } from './src/ui/kit'
29import { MAIN_PANE, renderMainPane } from './src/ui/pane'
30import { renderSettings, SETTINGS_PANE } from './src/ui/settings'
31import { renderShowcase, SHOWCASE_PANE } from './src/ui/showcase'
32
33// The session values, held by the host so a hot reload keeps them.
34const configAtom = atom({ plugin: 'big-bear', key: 'config' } as const, DEFAULT_CONFIG)
35const decisionAtom = atom({ plugin: 'big-bear', key: 'decision' } as const, null)
36const tasksAtom = atom({ plugin: 'big-bear', key: 'tasks' } as const, [])
37const metricsAtom = atom({ plugin: 'big-bear', key: 'metrics' } as const, emptyMetrics())
38const usageAtom = atom({ plugin: 'big-bear', key: 'usage' } as const, null)
39const gitAtom = atom({ plugin: 'big-bear', key: 'git' } as const, null)
40const qaAtom = atom({ plugin: 'big-bear', key: 'qa' } as const, {
41 profile: null,
42 startedAt: null,
43 endedAt: null,
44 steps: [],
45 lastResult: null,
46} satisfies BigBearQa)
47const alertsAtom = atom({ plugin: 'big-bear', key: 'alerts' } as const, [])
48const viewAtom = atom({ plugin: 'big-bear', key: 'view' } as const, { expanded: null, showAll: false })
49const tickAtom = atom({ plugin: 'big-bear', key: 'tick' } as const, 0)
50const projectAtom = atom({ plugin: 'big-bear', key: 'project' } as const, '')
51const activityAtom = atom({ plugin: 'big-bear', key: 'activity' } as const, 'idle')
52
53
54/**
55 * BIG BEAR DEV ORCHESTRATOR — Code Smarter. Build Faster. Spend Less.
56 *
57 * This file is the event-capture layer and the only one that holds `$`: the
58 * engine follows `$` into functions of this file alone, so it hands the
59 * modules an `Io` built here. Each event is its own hook, so a module that
60 * fails is skipped by the engine (or answered by its `.catch`) while the
61 * rest keep running.
62 */
63function io($: EngineInterface): Io {
64 return {
65 // Each value spelled out: the engine reads every state access statically.
66 state: {
67 config: { get: () => read($, configAtom), set: change => update($, configAtom, change) },
68 decision: { get: () => read($, decisionAtom), set: change => update($, decisionAtom, change) },
69 tasks: { get: () => read($, tasksAtom), set: change => update($, tasksAtom, change) },
70 metrics: { get: () => read($, metricsAtom), set: change => update($, metricsAtom, change) },
71 usage: { get: () => read($, usageAtom), set: change => update($, usageAtom, change) },
72 git: { get: () => read($, gitAtom), set: change => update($, gitAtom, change) },
73 qa: { get: () => read($, qaAtom), set: change => update($, qaAtom, change) },
74 alerts: { get: () => read($, alertsAtom), set: change => update($, alertsAtom, change) },
75 view: { get: () => read($, viewAtom), set: change => update($, viewAtom, change) },
76 tick: { get: () => read($, tickAtom), set: change => update($, tickAtom, change) },
77 project: { get: () => read($, projectAtom), set: change => update($, projectAtom, change) },
78 activity: { get: () => read($, activityAtom), set: change => update($, activityAtom, change) },
79 },
80 clock: {
81 now: () => $.clock.now(),
82 after: (ms, fn) => $.clock.after(ms, fn),
83 every: (ms, fn) => $.clock.every(ms, fn),
84 },
85 ui: {
86 toast: (text, options) => $.ui.toast(text, options),
87 log: (text, options) => $.ui.log(text, options),
88 open: pane => $.ui.open(pane),
89 close: pane => $.ui.close(pane),
90 panes: () => $.ui.panes(),
91 ask: (question, options) => $.ui.ask(question, options),
92 copy: args => $.ui.copy(args),
93 },
94 store: {
95 get: key => $.store.get(key),
96 set: (key, value) => $.store.set(key, value),
97 delete: key => $.store.delete(key),
98 keys: () => $.store.keys(),
99 },
100 process: { run: (argv, init) => $.process.run(argv, init) },
101 fs: { list: path => $.fs.list(path), read: path => $.fs.read(path) },
102 session: {
103 root: () => $.session.root(),
104 model: () => $.session.model(),
105 usage: args => $.session.usage(args),
106 version: () => $.session.version(),
107 surfaces: () => $.session.surfaces(),
108 },
109 command: { list: () => $.command.list(), register: spec => $.command.register(spec) },
110 model: {
111 complete: (request, options) => $.model.complete(request, options),
112 classify: (text, labels, options) => $.model.classify(text, labels, options),
113 },
114 prompt: { submit: input => $.prompt.submit(input) },
115 }
116}
117
118export const register: Register = on => {
119 on('session.start', async ($, e, next) => {
120 const x = io($)
121 runtime.session = x
122 runtime.root = await $.session.root()
123 await update($, projectAtom, () => runtime.root)
124
125 const config = await loadSettings(x, runtime.root)
126 await update($, configAtom, () => config)
127 runtime.history = await loadHistory(x)
128
129 // Ids continue after the tasks the host kept across a reload.
130 const tasks = await read($, tasksAtom)
131 runtime.nextId = tasks.reduce((max, task) => Math.max(max, Number(task.id) || 0), 0) + 1
132
133 const clash = (await $.command.list()).find(command => command.name === COMMAND && command.source !== 'plugin')
134 if (clash !== undefined) $.ui.log(`big-bear: /${COMMAND} também existe como comando ${clash.source}.`)
135 await $.command.register({
136 name: COMMAND,
137 description: '🐻 BIG BEAR: central de orquestração (status, mode, qa, bug, settings, showcase, doctor...)',
138 argumentHint: '[status|mode eco|balanced|god|model|qa|bug|tasks|usage|models|history|settings|showcase|doctor|on|off|help]',
139 })
140
141 const usage = await $.session.usage()
142 const now = await $.clock.now()
143 await update($, usageAtom, () => usageSnapshot(now, usage))
144 if (config.modules.gitGuardian) void refreshGit(x, runtime.root)
145
146 // One ticker, writing only while a task runs, so elapsed times move.
147 $.clock.every(config.ui.refreshMs, async () => {
148 const list = await read($, tasksAtom)
149 if (list.some(isOpen)) await update($, tickAtom, n => n + 1)
150 })
151
152 if (config.ui.autoOpenPane && config.modules.taskCommand) {
153 const isUp = (await $.ui.panes()).some(pane => pane.id === MAIN_PANE)
154 if (!isUp) void $.ui.open({ id: MAIN_PANE, title: 'BIG BEAR' })
155 }
156 return next(e)
157 }).catch(($, e, next) => next(e))
158
159 // ── Smart Brain and Task Command ──────────────────────────────────────────
160 on('prompt.submit', async ($, e, next) => {
161 await onPromptSubmit(io($), e.text)
162 return next(e)
163 }).catch(($, e, next) => next(e))
164
165 on('turn.start', async ($, e, next) => {
166 await onTurnStart(io($), e.turnId, e.text)
167 return next(e)
168 }).catch(($, e, next) => next(e))
169
170 on('turn.step', async function* ($, e, next) {
171 let model: string | null = null
172 try {
173 model = await beforeStep(io($), e.turnId, e.agentId, e.model)
174 } catch {
175 model = null
176 }
177 const result = yield* next(model === null ? e : { ...e, model })
178 try {
179 await afterStep(io($), e.turnId, e.agentId, result.usage as ApiUsage | null, result.toolUses[0]?.name)
180 } catch {
181 // Metrics are best effort: the response stands either way.
182 }
183 return result
184 })
185
186 on('turn.complete', async ($, e, next) => {
187 await onTurnComplete(io($), {
188 turnId: e.turnId,
189 agentId: e.agentId,
190 reason: e.reason,
191 refusalCategory: e.reason === 'refusal' ? e.refusal.category : undefined,
192 answer: e.answer,
193 })
194 return next(e)
195 }).catch(($, e, next) => next(e))
196
197 on('agent.spawn', async ($, e, next) => {
198 const planned = await beforeSpawn(io($), {
199 description: e.description,
200 prompt: e.prompt,
201 subagentType: e.subagentType,
202 model: e.model,
203 parentModel: e.parentModel,
204 fork: e.fork,
205 })
206 const result = await next(planned.model === null ? e : { ...e, model: planned.model })
207 await afterSpawn(io($), planned.taskId, result)
208 return result
209 }).catch(($, e, next) => next(e))
210
211 on('tool.call', async ($, e, next) => {
212 const tool = String(e.tool)
213 let task: BigBearTask | undefined
214 try {
215 task = await beforeTool(io($), tool, e.agentId)
216 } catch {
217 task = undefined
218 }
219 const result = await next(e)
220 await afterTool(io($), task, tool, result.deny !== undefined || result.isError === true)
221 return result
222 }).catch(($, e, next) => next(e))
223
224 // ── Code Guardian and Git Guardian ────────────────────────────────────────
225 on('tool.call', { tool: 'Bash' }, async ($, e, next) => (await guardShell(io($), 'Bash', String(e.command ?? ''))) ?? next(e)).catch(($, e, next) =>
226 next.called ? next(e) : (shellFallback(String(e.command ?? '')) ?? next(e)),
227 )
228
229 // PowerShell is a tool of some builds only: its name is not in this build's table.
230 on('tool.call', { tool: 'PowerShell' as never }, async ($, e, next) => {
231 const command = String((e as unknown as { command?: unknown }).command ?? '')
232 return (await guardShell(io($), 'PowerShell', command)) ?? next(e)
233 }).catch(($, e, next) => (next.called ? next(e) : (shellFallback(String((e as unknown as { command?: unknown }).command ?? '')) ?? next(e))))
234
235 on('tool.call', { tool: 'Edit' }, async ($, e, next) => (await guardEdit(io($), 'Edit', e as unknown as EditInput)) ?? next(e)).catch(($, e, next) => next(e))
236 on('tool.call', { tool: 'Write' }, async ($, e, next) => (await guardEdit(io($), 'Write', e as unknown as EditInput)) ?? next(e)).catch(($, e, next) => next(e))
237 on('tool.call', { tool: 'MultiEdit' as never }, async ($, e, next) => (await guardEdit(io($), 'MultiEdit', e as unknown as EditInput)) ?? next(e)).catch(
238 ($, e, next) => next(e),
239 )
240
241 // ── Usage Monitor and Context Manager ─────────────────────────────────────
242 on('session.measure', async ($, e, next) => {
243 await onMeasure(io($), e)
244 return next(e)
245 }).catch(($, e, next) => next(e))
246
247 // ── Commands ──────────────────────────────────────────────────────────────
248 on('command.run', { command: COMMAND }, async ($, e) => runCommand(io($), e.args)).catch(() => ({
249 text: '🐻 BIG BEAR: o comando falhou. Tente /bigbear doctor.',
250 }))
251
252 // ── Interface ─────────────────────────────────────────────────────────────
253 on('ui.render', { component: 'Pane', requestId: MAIN_PANE }, async ($, e) =>
254 renderMainPane(kitOf($.ui.resolve(e), e.surface), io($), e.props.bodyColumns, e.props.scroll.bodyRows),
255 )
256
257 on('ui.render', { component: 'Pane', requestId: SETTINGS_PANE }, async ($, e) => renderSettings(kitOf($.ui.resolve(e), e.surface), io($), e.props.bodyColumns))
258
259 on('ui.render', { component: 'Pane', requestId: SHOWCASE_PANE }, async ($, e) => renderShowcase(kitOf($.ui.resolve(e), e.surface), io($), e.props.bodyColumns))
260
261 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
262 const config = await read($, configAtom)
263 if (e.props.hasSurvey || !config.ui.showBand || !config.modules.usageMonitor) return next(e)
264 return renderBand(kitOf($.ui.resolve(e), e.surface), io($), e.props.bodyColumns)
265 })
266}
267hooks/src/commands.ts 416 lines1import type { CommandRunResult } from 'claude-code'
2import type { Io } from './services/io'
3
4import type { BigBearConfig, BigBearMode, BigBearTier } from '../../types'
5import { TIERS } from './config/defaults'
6import { runtime } from './core/runtime'
7import { overall } from './core/tasks'
8import { pendingChanges } from './modules/git'
9import { redactPaths, redactSecrets } from './modules/guardian'
10import { availableProfiles, detectChecks, planQa } from './modules/qa'
11import { summarize } from './services/analytics'
12import {
13 addDecision,
14 clearProjectPolicy,
15 loadDecisions,
16 projectFacts,
17 refreshGit,
18 resetSettings,
19 runQa,
20 saveSettings,
21} from './services/engine'
22import { distribution, savings, sessionCost, sessionTokens, totalTokens } from './services/pricing'
23import { compact, duration, projectName, untilReset, usd } from './utils/format'
24import { MAIN_PANE } from './ui/pane'
25import { SETTINGS_PANE } from './ui/settings'
26import { SHOWCASE_PANE, showcaseText } from './ui/showcase'
27
28
29export const COMMAND = 'bigbear'
30
31const HELP = [
32 '🐻 BIG BEAR — comandos',
33 '/bigbear abre/fecha a central',
34 '/bigbear status estado geral',
35 '/bigbear mode eco|balanced|god [--project]',
36 '/bigbear model auto|haiku|sonnet|opus fixa ou libera o modelo',
37 '/bigbear block|unblock <tier> impede/libera um modelo',
38 '/bigbear on | off liga/desliga o roteamento automático',
39 '/bigbear tasks | usage | models | history | summary',
40 '/bigbear qa [perfil|list] roda testes reais do projeto',
41 '/bigbear bug <erro> Bug Hunter: investigação guiada',
42 '/bigbear decision <texto> registra uma decisão técnica',
43 '/bigbear settings | showcase [export] | doctor [probe]',
44 '/bigbear price <tier> <in> <out> <cacheRead> <cacheWrite> USD/MTok',
45 '/bigbear set-model <tier> <id> troca o modelo de um nível',
46 '/bigbear alerts <aviso> <elevado> <crítico> limiares de uso (%)',
47 '/bigbear qa-profile <perfil> perfil padrão do Auto QA',
48 '/bigbear project reset | reset',
49].join('\n')
50
51async function setConfig(io: Io, edit: (c: BigBearConfig) => void, scope: 'global' | 'project' = 'global'): Promise<BigBearConfig> {
52 const config = JSON.parse(JSON.stringify(await io.state.config.get())) as BigBearConfig
53 edit(config)
54 await saveSettings(io, config, scope)
55 return config
56}
57
58function isTier(value: string | undefined): value is BigBearTier {
59 return value !== undefined && (TIERS as readonly string[]).includes(value)
60}
61
62async function statusText(io: Io): Promise<string> {
63 const [config, tasks, metrics, usage, git, qa, decision] = await Promise.all([
64 io.state.config.get(),
65 io.state.tasks.get(),
66 io.state.metrics.get(),
67 io.state.usage.get(),
68 io.state.git.get(),
69 io.state.qa.get(),
70 io.state.decision.get(),
71 ])
72 const now = await io.clock.now()
73 const all = overall(tasks, now)
74 const save = savings(metrics)
75 const pct = (w: { percent: number; resetsAt?: string } | null | undefined) =>
76 w === null || w === undefined ? 'N/D — indisponível' : `${Math.round(w.percent)}% (reset em ${untilReset(w.resetsAt, now)})`
77 return [
78 `🐻 BIG BEAR · ${projectName(runtime.root)} · ${config.routing.mode.toUpperCase()}${config.routing.enabled ? '' : ' · roteamento OFF'}${
79 config.routing.manualTier !== null ? ` · manual ${config.routing.manualTier.toUpperCase()}` : ''
80 }`,
81 `Último roteamento: ${decision === null ? '—' : `${decision.tier.toUpperCase()} (${decision.model}) — ${decision.reasons.join('; ')}`}`,
82 `Sessão do Claude: ${await io.session.model()}`,
83 `Tarefas: ${all.done}/${all.total} · ${all.completed} concluídas · ${all.failed} falhas · ${duration(all.elapsedMs)}`,
84 `Tokens: ${compact(totalTokens(sessionTokens(metrics)))} · eq. API ${usd(sessionCost(metrics))} · economia ${save.isAvailable ? `${save.percent.toFixed(1)}% vs ${config.pricing.baseline}${save.tokensSaved > 0 ? ` (~${compact(save.tokensSaved)} tokens poupados, estimativa)` : ''}` : 'indisponível'}`,
85 `Uso 5H: ${pct(usage?.fiveHour)} · Semana: ${pct(usage?.week)} · Contexto: ${usage?.contextPercent ?? 'N/D'}%`,
86 `Git: ${git === null ? '—' : git.isRepo ? `${git.branch ?? 'HEAD'} · ${pendingChanges(git)} alterações` : 'não é repositório'} · QA: ${qa.lastResult ?? 'não executado'}`,
87 ].join('\n')
88}
89
90async function usageText(io: Io): Promise<string> {
91 const usage = await io.session.usage()
92 const now = await io.clock.now()
93 const lines = ['🐻 USAGE (dados do próprio Claude Code: io.session.usage)']
94 if (usage.rateLimits.length === 0) lines.push('Janelas de limite: N/D — indisponível (sem assinatura ou nenhuma resposta ainda).')
95 for (const limit of usage.rateLimits) {
96 lines.push(`${limit.kind}: ${limit.percentUsed}%${limit.resetsAt !== undefined ? ` · reset em ${untilReset(limit.resetsAt, now)}` : ''}`)
97 }
98 lines.push(
99 `Contexto: ${usage.context.percent ?? 'N/D'}% de ${compact(usage.context.window)} tokens (janela de contexto ≠ limite da conta)`,
100 `Custo da sessão (ledger do Claude Code): ${usage.cost !== undefined ? usd(usage.cost.usd) : 'N/D'}`,
101 )
102 return lines.join('\n')
103}
104
105async function modelsText(io: Io): Promise<string> {
106 const config = await io.state.config.get()
107 const metrics = await io.state.metrics.get()
108 const share = distribution(metrics)
109 const lines = [`🐻 MODELOS · sessão atual: ${await io.session.model()}`]
110 for (const tier of TIERS) {
111 const price = config.pricing.table[tier]
112 lines.push(
113 `${tier.toUpperCase().padEnd(7)} ${config.routing.models[tier].padEnd(20)} ${config.routing.blocked.includes(tier) ? 'BLOQUEADO ' : ''}` +
114 `$${price.input}/$${price.output} por MTok · uso ${share === null ? '—' : `${share[tier]}%`} · ${compact(totalTokens(metrics.byTier[tier]))} tok`,
115 )
116 }
117 for (const [model, bucket] of Object.entries(metrics.other)) lines.push(`outro ${model} · ${compact(totalTokens(bucket))} tok (sem preço)`)
118 lines.push(`Preços: ${config.pricing.source} (${config.pricing.updatedAt}).`, 'Disponibilidade real: /bigbear doctor probe (faz 1 chamada mínima por modelo).')
119 return lines.join('\n')
120}
121
122async function historyText(): Promise<string> {
123 const s = summarize(runtime.history)
124 if (s.total === 0) return '🐻 HISTÓRICO: vazio. Ele cresce a cada tarefa concluída (só métricas, nunca código ou prompts).'
125 const lines = [
126 `🐻 HISTÓRICO · ${s.total} tarefas · ${s.completed} ok · ${s.failed} falhas · ${s.cancelled} canceladas · média ${duration(s.avgMs)}`,
127 `Escalonamentos: ${s.escalations} · tokens ${compact(s.tokens)} · eq. API ${usd(s.costUsd)}`,
128 `Por modelo: ${TIERS.map(t => `${t.toUpperCase()} ${s.byTier[t]}`).join(' · ')}`,
129 'Por tipo:',
130 ...s.byCategory.slice(0, 8).map(c => ` ${c.category.padEnd(13)} ${String(c.runs).padStart(4)}× · sucesso ${(c.successRate * 100).toFixed(0)}% · ${duration(c.avgMs)}`),
131 'Por projeto:',
132 ...s.byProject.slice(0, 5).map(p => ` ${projectName(p.project).padEnd(24)} ${p.runs}× · ${usd(p.costUsd)}`),
133 ]
134 return lines.join('\n')
135}
136
137async function qaCommand(io: Io, arg: string): Promise<CommandRunResult> {
138 const config = await io.state.config.get()
139 if (!config.modules.autoQa) return { text: 'Auto QA está desligado (Settings → Módulos).' }
140 const facts = await projectFacts(io, runtime.root)
141 const checks = detectChecks(facts)
142 if (arg === 'list' || arg === 'perfis') {
143 return {
144 text: [
145 `🐻 AUTO QA · perfis: ${availableProfiles(facts).join(', ')}`,
146 `Detectado: typecheck=${checks.typecheck ?? '—'} · lint=${checks.lint ?? '—'} · test=${checks.test ?? '—'} · build=${checks.build ?? '—'}`,
147 `Perfil padrão: ${config.qa.profile} · automático após edições: ${config.qa.autoRun ? 'sim' : 'não'}`,
148 ].join('\n'),
149 }
150 }
151 if (runtime.qaRunning) return { text: 'Auto QA já está rodando.' }
152 const plan = planQa(facts, arg || config.qa.profile)
153 if (plan.steps.length === 0) return { text: ['🐻 AUTO QA: nada a executar.', ...plan.notes].join('\n') }
154 runtime.qaRunning = true
155 try {
156 await io.ui.open({ id: MAIN_PANE, title: 'BIG BEAR' })
157 await runQa(io, runtime.root, plan)
158 } finally {
159 runtime.qaRunning = false
160 }
161 const qa = await io.state.qa.get()
162 const lines = [`🐻 AUTO QA · ${plan.profile}: ${qa.lastResult === 'passed' ? 'PASSOU' : 'FALHOU'}`]
163 for (const step of qa.steps) {
164 lines.push(`${step.status === 'passed' ? '✓' : '✗'} ${step.name} · exit ${step.exitCode} · ${duration(step.durationMs)}`)
165 }
166 lines.push(...plan.notes)
167 const failures = qa.steps.filter(step => step.status === 'failed' && step.tail)
168 return {
169 text: lines.join('\n'),
170 // The model reads the failing output so it can act on it; the person sees the summary.
171 context: failures.length > 0 ? [`BIG BEAR Auto QA falhou. Saída final dos passos com falha:\n${failures.map(f => `## ${f.name}\n${f.tail}`).join('\n\n')}`] : undefined,
172 }
173}
174
175async function doctorText(io: Io, probe: boolean): Promise<string> {
176 const config = await io.state.config.get()
177 const version = await io.session.version()
178 const surfaces = await io.session.surfaces()
179 const commands = await io.command.list()
180 const clashes = commands.filter(c => c.name === COMMAND && c.source !== 'plugin')
181 const usage = await io.session.usage()
182 const panes = await io.ui.panes()
183 const lines = [
184 '🐻 BIG BEAR DOCTOR',
185 `Claude Code ${version.version} · superfícies: ${surfaces.join(', ') || 'nenhuma (headless)'}`,
186 `Projeto: ${projectName(runtime.root)} · histórico: ${runtime.history.length} registros`,
187 `Comando /${COMMAND}: ${clashes.length === 0 ? 'ok, sem conflito' : `CONFLITO com ${clashes.map(c => c.source).join(', ')}`}`,
188 `Painéis abertos: ${panes.map(p => `${p.id}${p.isPlaced ? '' : ' (aguardando largura)'}`).join(', ') || 'nenhum'}`,
189 `Rate limits: ${usage.rateLimits.length > 0 ? usage.rateLimits.map(r => r.kind).join(', ') : 'N/D (sem assinatura ou sem resposta ainda)'}`,
190 `Roteamento: ${config.routing.enabled ? 'ON' : 'OFF'} · modo ${config.routing.mode} · módulos ativos ${Object.values(config.modules).filter(Boolean).length}/11`,
191 surfaces.includes('vscode') ? 'Aviso: no chat da extensão VS Code o painel não aparece; use /bigbear status.' : '',
192 ].filter(Boolean)
193 if (probe) {
194 lines.push('Sondagem de modelos (1 chamada mínima cada):')
195 for (const tier of TIERS) {
196 const model = config.routing.models[tier]
197 const reply = await io.model.complete({ model, prompt: 'Reply with OK.', maxTokens: 16 })
198 lines.push(
199 ` ${tier.toUpperCase().padEnd(7)} ${model}: ${reply.isAnswered ? 'disponível' : `indisponível (${reply.reason}${reply.reason === 'api-error' ? ` ${reply.status ?? ''} ${reply.error ?? ''}` : ''})`}`,
200 )
201 }
202 } else {
203 lines.push('Para confirmar cada modelo na sua conta: /bigbear doctor probe')
204 }
205 return lines.join('\n')
206}
207
208function bugPrompt(error: string): string {
209 return [
210 'BIG BEAR · Bug Hunter. Investigue e corrija o erro abaixo seguindo este fluxo, nesta ordem:',
211 '1. Identifique os componentes envolvidos.',
212 '2. Investigue arquivos e logs relevantes (leitura antes de editar).',
213 '3. Liste hipóteses e descarte as que a evidência não sustenta.',
214 '4. Implemente a menor correção segura. Não altere comportamento não relacionado nem ajuste regras de jogo para passar em teste.',
215 '5. Rode os testes relevantes do projeto e confira regressões.',
216 '6. Se a causa for arquitetural, pare e explique antes de refatorar.',
217 '7. Termine com: causa raiz, correção, testes executados e resultado.',
218 '',
219 'Erro relatado:',
220 error,
221 ].join('\n')
222}
223
224/** /bigbear and its subcommands. */
225export async function runCommand(io: Io, args: string): Promise<CommandRunResult> {
226 {
227 const [sub = '', ...rest] = args.trim().split(/\s+/).filter(Boolean)
228 const arg = rest.join(' ')
229 const lower = sub.toLowerCase()
230
231 switch (lower) {
232 case '': {
233 const isOpen = (await io.ui.panes()).some(p => p.id === MAIN_PANE)
234 if (isOpen) {
235 await io.ui.close({ id: MAIN_PANE })
236 return { text: '🐻 Central fechada.' }
237 }
238 const opened = await io.ui.open({ id: MAIN_PANE, title: 'BIG BEAR' })
239 return { text: opened.isPlaced ? '🐻 Central aberta.' : `🐻 Central aguardando espaço: ${opened.reason}` }
240 }
241 case 'help':
242 case 'ajuda':
243 return { text: HELP }
244 case 'status':
245 return { text: await statusText(io) }
246 case 'mode':
247 case 'modo': {
248 const mode = rest[0]?.toLowerCase() as BigBearMode | undefined
249 if (mode !== 'eco' && mode !== 'balanced' && mode !== 'god') return { text: 'Uso: /bigbear mode eco|balanced|god [--project]' }
250 const scope = rest.includes('--project') ? 'project' : 'global'
251 await setConfig(io, c => (c.routing.mode = mode), scope)
252 return { text: `🐻 ${mode.toUpperCase()} MODE ativado${scope === 'project' ? ' para este projeto' : ''}.` }
253 }
254 case 'model':
255 case 'modelo': {
256 const target = rest[0]?.toLowerCase()
257 if (target === 'auto') {
258 await setConfig(io, c => (c.routing.manualTier = null))
259 return { text: '🐻 Modelo automático (Smart Brain decide).' }
260 }
261 if (!isTier(target)) return { text: 'Uso: /bigbear model auto|haiku|sonnet|opus' }
262 await setConfig(io, c => {
263 c.routing.manualTier = target
264 c.routing.enabled = true
265 })
266 return { text: `🐻 Modelo fixado: ${target.toUpperCase()}. /bigbear model auto para liberar.` }
267 }
268 case 'block':
269 case 'unblock': {
270 const target = rest[0]?.toLowerCase()
271 if (!isTier(target)) return { text: `Uso: /bigbear ${lower} haiku|sonnet|opus` }
272 const config = await setConfig(io, c => {
273 c.routing.blocked = lower === 'block' ? [...new Set([...c.routing.blocked, target])] : c.routing.blocked.filter(t => t !== target)
274 })
275 return { text: `🐻 Bloqueados: ${config.routing.blocked.join(', ') || 'nenhum'}.` }
276 }
277 case 'off':
278 await setConfig(io, c => (c.routing.enabled = false))
279 return { text: '🐻 Roteamento automático DESLIGADO. O Claude Code usa o modelo da sessão normalmente; o painel segue acompanhando.' }
280 case 'on':
281 await setConfig(io, c => (c.routing.enabled = true))
282 return { text: '🐻 Roteamento automático LIGADO.' }
283 case 'tasks':
284 case 'tarefas': {
285 const tasks = await io.state.tasks.get()
286 await io.ui.open({ id: MAIN_PANE, title: 'BIG BEAR' })
287 const now = await io.clock.now()
288 const lines = tasks
289 .slice(-15)
290 .map(t => `${t.kind === 'subagent' ? ' └ ' : ''}#${t.id} ${t.status.padEnd(10)} ${t.tier.toUpperCase().padEnd(6)} ${duration((t.endedAt ?? now) - t.startedAt).padStart(6)} ${t.title.slice(0, 60)}`)
291 return { text: ['🐻 TAREFAS', ...(lines.length > 0 ? lines : ['nenhuma ainda'])].join('\n') }
292 }
293 case 'usage':
294 case 'uso':
295 return { text: await usageText(io) }
296 case 'models':
297 case 'modelos':
298 return { text: await modelsText(io) }
299 case 'settings':
300 case 'config':
301 await io.ui.open({ id: SETTINGS_PANE, title: 'BIG BEAR · Settings', focus: true, closeOnEscape: true })
302 return { text: '🐻 Configurações abertas.' }
303 case 'qa':
304 return qaCommand(io, arg)
305 case 'history':
306 case 'historico':
307 return { text: await historyText() }
308 case 'summary':
309 case 'resumo': {
310 const tasks = await io.state.tasks.get()
311 const decisions = await loadDecisions(io, runtime.root)
312 const done = tasks.filter(t => t.kind === 'turn' && t.endedAt !== undefined).slice(-10)
313 return {
314 text: [
315 `🐻 RESUMO TÉCNICO · ${projectName(runtime.root)}`,
316 'Tarefas recentes (estado verificado por evento):',
317 ...done.map(t => ` #${t.id} ${t.status} · ${t.tier.toUpperCase()} · ${t.category} · ${t.title.slice(0, 70)}`),
318 'Decisões registradas:',
319 ...(decisions.length > 0 ? decisions.slice(-10).map(d => ` • ${d.text}`) : [' nenhuma (/bigbear decision <texto>)']),
320 ].join('\n'),
321 }
322 }
323 case 'decision':
324 case 'decisao': {
325 if (arg.length === 0) return { text: 'Uso: /bigbear decision <texto da decisão>' }
326 const list = await addDecision(io, runtime.root, arg, await io.clock.now())
327 return { text: `🐻 Decisão registrada (${list.length} no projeto).` }
328 }
329 case 'showcase': {
330 if (rest[0] === 'export') {
331 const [config, tasks, metrics] = await Promise.all([io.state.config.get(), io.state.tasks.get(), io.state.metrics.get()])
332 const text = showcaseText(config, tasks, metrics, await io.clock.now())
333 const copied = await io.ui.copy({ text })
334 return { text: `${text}\n\n${copied.isCopied ? '(copiado para a área de transferência, sem caminhos nem segredos)' : '(sem área de transferência nesta superfície: copie o texto acima)'}` }
335 }
336 await io.ui.open({ id: SHOWCASE_PANE, title: 'BIG BEAR · Showcase', focus: true, closeOnEscape: true })
337 return { text: '🐻 Showcase aberto. /bigbear showcase export gera o resumo para compartilhar.' }
338 }
339 case 'doctor':
340 return { text: await doctorText(io, rest[0] === 'probe') }
341 case 'bug': {
342 const config = await io.state.config.get()
343 if (!config.modules.bugHunter) return { text: 'Bug Hunter está desligado (Settings → Módulos).' }
344 if (arg.length === 0) return { text: 'Uso: /bigbear bug <mensagem de erro ou descrição>' }
345 const tier = config.routing.blocked.includes('sonnet') ? 'opus' : 'sonnet'
346 runtime.forced = {
347 tier,
348 source: 'bughunter',
349 reasons: [`Bug Hunter: investigação em ${tier.toUpperCase()}; escalona para OPUS se a causa for arquitetural`],
350 }
351 await io.ui.open({ id: MAIN_PANE, title: 'BIG BEAR' })
352 void io.prompt.submit({ text: bugPrompt(redactSecrets(arg)) })
353 return { text: '🐻 Bug Hunter iniciado.' }
354 }
355 case 'price':
356 case 'preco': {
357 const [tier, ...values] = rest
358 const numbers = values.map(Number)
359 if (!isTier(tier) || numbers.length !== 4 || numbers.some(n => !Number.isFinite(n) || n < 0)) {
360 return { text: 'Uso: /bigbear price <haiku|sonnet|opus> <input> <output> <cacheRead> <cacheWrite> (USD por milhão de tokens)' }
361 }
362 const today = new Date(await io.clock.now()).toISOString().slice(0, 10)
363 await setConfig(io, c => {
364 c.pricing.table[tier] = { input: numbers[0]!, output: numbers[1]!, cacheRead: numbers[2]!, cacheWrite: numbers[3]! }
365 c.pricing.source = `editado pelo usuário em ${today}`
366 c.pricing.updatedAt = today
367 })
368 return { text: `🐻 Preço de ${tier.toUpperCase()} atualizado.` }
369 }
370 case 'set-model': {
371 const [tier, id] = rest
372 if (!isTier(tier) || id === undefined || !/^[\w.:@/-]{3,100}$/.test(id)) {
373 return { text: 'Uso: /bigbear set-model <haiku|sonnet|opus> <id do modelo>. Confirme depois com /bigbear doctor probe.' }
374 }
375 await setConfig(io, c => (c.routing.models[tier] = id))
376 return { text: `🐻 ${tier.toUpperCase()} agora usa ${id}. Rode /bigbear doctor probe para confirmar que ele existe na sua conta.` }
377 }
378 case 'alerts':
379 case 'alertas': {
380 const values = rest.map(Number)
381 if (values.length !== 3 || values.some(v => !Number.isFinite(v) || v <= 0 || v > 100) || !(values[0]! < values[1]! && values[1]! < values[2]!)) {
382 return { text: 'Uso: /bigbear alerts <aviso> <elevado> <crítico>, ex.: /bigbear alerts 70 85 95' }
383 }
384 await setConfig(io, c => {
385 c.usage.warn = values[0]!
386 c.usage.high = values[1]!
387 c.usage.critical = values[2]!
388 })
389 return { text: `🐻 Alertas de uso: ${values.join('% / ')}%.` }
390 }
391 case 'qa-profile': {
392 if (arg.length === 0) return { text: 'Uso: /bigbear qa-profile <auto|quick|standard|full|ragnarok:...>' }
393 await setConfig(io, c => (c.qa.profile = arg))
394 return { text: `🐻 Perfil padrão de QA: ${arg}.` }
395 }
396 case 'project':
397 case 'projeto':
398 if (rest[0] === 'reset') {
399 await clearProjectPolicy(io)
400 return { text: '🐻 Política do projeto removida: vale a configuração global.' }
401 }
402 return { text: 'Uso: /bigbear mode <modo> --project, ou /bigbear project reset' }
403 case 'reset':
404 await resetSettings(io)
405 return { text: '🐻 Configurações restauradas para o padrão.' }
406 case 'git': {
407 const git = await refreshGit(io, runtime.root)
408 return { text: git.isRepo ? `⎇ ${git.branch} · ${pendingChanges(git)} alterações · ${git.lastCommit ?? ''}` : 'Não é um repositório git.' }
409 }
410 default:
411 return { text: `Subcomando desconhecido: ${redactPaths(sub)}\n\n${HELP}` }
412 }
413 }
414}
415
416hooks/src/core/orchestrator.ts 350 lines1import type { On } from 'claude-code'
2import type { Io } from '../services/io'
3
4import type { BigBearActivity, BigBearCategory, BigBearDecision, BigBearTask } from '../../../types'
5import { refreshGit, alert, runQa, projectFacts, saveHistory } from '../services/engine'
6import { appendHistory, estimateMs, failureStats, toRecord } from '../services/analytics'
7import { addStep, priceStep, totalTokens } from '../services/pricing'
8import { compact, duration } from '../utils/format'
9import type { ApiUsage } from '../services/pricing'
10import { redactPaths, redactSecrets } from '../modules/guardian'
11import { planQa } from '../modules/qa'
12import { classify } from './classifier'
13import { notificationSummary, titleFor } from './title'
14import type { Classification } from './classifier'
15import { decide, shouldEscalate, subagentTier, tierOfModel } from './router'
16import { newId, runtime } from './runtime'
17import { MAX_TASKS } from './state'
18import { activityForTasks, activityForTool, addUsage, finish, isOpen, newTask, patch } from './tasks'
19
20
21const CATEGORIES: readonly BigBearCategory[] = ['lookup', 'docs', 'test', 'bugfix', 'feature', 'refactor', 'architecture', 'audit', 'critical']
22const EDIT_TOOLS = /^(Edit|Write|MultiEdit|NotebookEdit)$/
23
24/** The task's title: its kind and a clean summary, never raw markup (see core/title.ts). */
25function title(text: string, category: BigBearCategory): string {
26 return titleFor(redactSecrets(text), category)
27}
28
29async function setActivity(io: Io, activity: BigBearActivity): Promise<void> {
30 const current = await io.state.activity.get()
31 if (current !== activity) await io.state.activity.set(() => activity)
32}
33
34async function writeTasks(io: Io, change: (tasks: BigBearTask[]) => BigBearTask[]): Promise<BigBearTask[]> {
35 return io.state.tasks.set(tasks => {
36 const next = change(tasks)
37 // Keep the newest; drop finished ones first.
38 if (next.length <= MAX_TASKS) return next
39 const overflow = next.length - MAX_TASKS
40 let dropped = 0
41 return next.filter(task => (dropped < overflow && !isOpen(task) ? (dropped++, false) : true))
42 })
43}
44
45/** Refines a weak rules verdict with one small-model call: opt-in only. */
46async function refine(io: Io, text: string, c: Classification): Promise<Classification> {
47 const config = await io.state.config.get()
48 if (!config.routing.aiClassifier || c.confidence >= 0.5 || c.category === 'continue' || c.category === 'trivial') return c
49 try {
50 const label = await io.model.classify(text.slice(0, 4000), CATEGORIES, { model: config.routing.models.haiku })
51 if (label === undefined) return c
52 return { ...c, category: label as BigBearCategory, signals: [...c.signals, `IA: ${label}`], confidence: 0.7 }
53 } catch {
54 return c
55 }
56}
57
58async function makeDecision(io: Io, text: string): Promise<BigBearDecision> {
59 const config = await io.state.config.get()
60 const usage = await io.state.usage.get()
61 const c = await refine(io, text, classify(notificationSummary(text) !== null ? '' : text))
62 const decision = decide(config, c, {
63 previous: runtime.previous,
64 now: await io.clock.now(),
65 contextTokens: usage?.contextTokens ?? null,
66 history: config.routing.learnFromHistory ? failureStats(runtime.history) : {},
67 })
68 if (runtime.forced !== null) {
69 const forced = runtime.forced
70 runtime.forced = null
71 return { ...decision, tier: forced.tier, model: config.routing.models[forced.tier], reasons: forced.reasons, source: forced.source }
72 }
73 return decision
74}
75
76const FINISH_ICON: Record<string, string> = { COMPLETED: '✓', FAILED: '✗', CANCELLED: '–' }
77const FINISH_WORD: Record<string, string> = { COMPLETED: 'concluída', FAILED: 'falhou', CANCELLED: 'cancelada' }
78
79/** The one-line notification for a finished task: outcome, model, title, time, tokens. */
80function finishMessage(task: BigBearTask): string {
81 const ms = (task.endedAt ?? task.startedAt) - task.startedAt
82 const tokens = totalTokens(task.tokens)
83 const title = task.title.length > 60 ? `${task.title.slice(0, 59)}…` : task.title
84 const parts = [`${FINISH_ICON[task.status] ?? '•'} ${task.kind === 'subagent' ? 'Subagente' : 'Tarefa'} ${FINISH_WORD[task.status] ?? task.status.toLowerCase()}`, task.tier.toUpperCase(), title, duration(ms)]
85 if (tokens > 0) parts.push(`${compact(tokens)} tok`)
86 return `🐻 ${parts.join(' · ')}`
87}
88
89async function finishTask(io: Io, taskId: string, status: BigBearTask['status'], extra: Partial<BigBearTask>): Promise<BigBearTask | undefined> {
90 const now = await io.clock.now()
91 const tasks = await writeTasks(io, list => patch(list, taskId, task => finish(task, status, now, extra)))
92 const task = tasks.find(t => t.id === taskId)
93 const config = await io.state.config.get()
94 if (task !== undefined && config.ui.notifyOnFinish) io.ui.toast(finishMessage(task))
95 if (task !== undefined && config.modules.devAnalytics && task.kind === 'turn') {
96 const record = toRecord(task, runtime.root)
97 if (record !== null) {
98 runtime.history = appendHistory(runtime.history, record)
99 await saveHistory(io, runtime.history)
100 }
101 }
102 return task
103}
104
105async function maybeRefreshGit(io: Io): Promise<void> {
106 const config = await io.state.config.get()
107 if (!config.modules.gitGuardian) return
108 const now = await io.clock.now()
109 if (now - runtime.lastGitAt < 5000) return
110 runtime.lastGitAt = now
111 await refreshGit(io, runtime.root)
112}
113
114/** Runs QA on the session's own `$`, after the turn that changed files ended. */
115function scheduleQa(taskId: string): void {
116 const session = runtime.session
117 if (session === null || runtime.qaRunning) return
118 runtime.qaRunning = true
119 session.clock.after(0, async () => {
120 try {
121 const config = await session.state.config.get()
122 const plan = planQa(await projectFacts(session, runtime.root), config.qa.profile)
123 const result = await runQa(session, runtime.root, plan)
124 const qa = await session.state.qa.get()
125 const failedStep = qa.steps.find(step => step.status === 'failed')
126 if (result === 'failed') {
127 await finishTask(session, taskId, 'FAILED', { error: `QA falhou: ${failedStep?.name ?? '?'}` })
128 await alert(session, 'autoQa', 'danger', `Auto QA falhou em ${failedStep?.name ?? '?'}`)
129 session.ui.toast(`🐻 Auto QA: falhou em ${failedStep?.name ?? '?'}`)
130 } else {
131 await finishTask(session, taskId, 'COMPLETED', { result: result === 'passed' ? 'verificado: QA passou' : 'verificado: turno respondido; perfil de QA sem passos' })
132 }
133 } catch (error) {
134 await finishTask(session, taskId, 'COMPLETED', { result: 'turno respondido; QA não pôde rodar', error: error instanceof Error ? error.message.slice(0, 200) : String(error) })
135 } finally {
136 runtime.qaRunning = false
137 await setActivity(session, activityForTasks(await session.state.tasks.get(), 'idle'))
138 }
139 })
140}
141
142// ─── Event handlers. hooks/register.tsx captures the events and calls these. ───
143
144/** prompt.submit — steps 1-8: intercept, classify, weigh risk, pick a model, record why. */
145export async function onPromptSubmit(io: Io, text: string): Promise<void> {
146 const config = await io.state.config.get()
147 if (text.trimStart().startsWith('/') || (!config.modules.smartBrain && !config.modules.taskCommand)) return
148 const decision = await makeDecision(io, text)
149 if (!config.modules.smartBrain) decision.reasons = ['Smart Brain desligado: só acompanhamento']
150 const routed = config.routing.enabled && config.modules.smartBrain
151 if (!routed) {
152 decision.model = await io.session.model()
153 decision.tier = tierOfModel(decision.model) ?? decision.tier
154 }
155 await io.state.decision.set(() => decision)
156 const id = newId()
157 const now = await io.clock.now()
158 await writeTasks(io, tasks => [
159 ...tasks,
160 newTask({ id, now, title: title(text, decision.category), decision, routed, etaMs: estimateMs(runtime.history, decision.category, decision.tier) }),
161 ])
162 runtime.pendingTaskId = id
163 await setActivity(io, 'research')
164}
165
166export async function onTurnStart(io: Io, turnId: string, text: string): Promise<void> {
167 const config = await io.state.config.get()
168 let taskId = runtime.pendingTaskId
169 runtime.pendingTaskId = null
170 const routed = config.routing.enabled && config.modules.smartBrain
171 if (taskId === null) {
172 // A turn no typed prompt began (a continuation, a delivery): tracked too.
173 const decision = await makeDecision(io, '')
174 if (!routed) {
175 decision.model = await io.session.model()
176 decision.tier = tierOfModel(decision.model) ?? decision.tier
177 }
178 const id = newId()
179 taskId = id
180 const now = await io.clock.now()
181 await writeTasks(io, tasks => [...tasks, newTask({ id, now, title: title(text, decision.category), decision, routed, etaMs: null })])
182 }
183 const id = taskId
184 const tasks = await writeTasks(io, list => patch(list, id, task => ({ ...task, turnId, status: 'RUNNING', milestones: Math.max(task.milestones, 2) })))
185 const task = tasks.find(t => t.id === id)
186 runtime.turns.set(turnId, { taskId: id, tier: task?.tier ?? 'sonnet', routed, escalations: 0, toolErrors: 0, hadEdits: false })
187}
188
189/**
190 * turn.step, before the request: steps 9-10. Returns the model the request
191 * should name, or null to leave it. Escalates one tier when the turn keeps
192 * failing, within the configured limit.
193 */
194export async function beforeStep(io: Io, turnId: string, agentId: string | undefined, model: string): Promise<string | null> {
195 if (agentId !== undefined) return null
196 const run = runtime.turns.get(turnId)
197 const config = await io.state.config.get()
198 if (run === undefined || !run.routed || !config.routing.enabled || !config.modules.smartBrain) return null
199 const up = shouldEscalate(config, { tier: run.tier, escalations: run.escalations, toolErrors: run.toolErrors })
200 if (up !== null) {
201 const from = run.tier
202 run.tier = up
203 run.escalations++
204 await io.state.metrics.set(m => ({ ...m, escalations: m.escalations + 1 }))
205 await writeTasks(io, list =>
206 patch(list, run.taskId, task => ({
207 ...task,
208 tier: up,
209 model: config.routing.models[up],
210 escalations: task.escalations + 1,
211 reasons: [...task.reasons, `escalonado ${from.toUpperCase()} → ${up.toUpperCase()} após ${run.toolErrors} erros de ferramenta`],
212 })),
213 )
214 io.ui.toast(`🐻 Escalonando ${from.toUpperCase()} → ${up.toUpperCase()}`)
215 }
216 const wanted = config.routing.models[run.tier]
217 return wanted === model ? null : wanted
218}
219
220/** turn.step, after the response: tokens and cost per model, the avatar. */
221export async function afterStep(io: Io, turnId: string, agentId: string | undefined, usage: ApiUsage | null, firstTool: string | undefined): Promise<void> {
222 const config = await io.state.config.get()
223 if (usage !== null && config.modules.tokenIntelligence) {
224 const step = priceStep(config, usage)
225 await io.state.metrics.set(m => addStep(m, usage.model, step))
226 const run = agentId === undefined ? runtime.turns.get(turnId) : undefined
227 const taskId = run?.taskId ?? (agentId !== undefined ? (await io.state.tasks.get()).find(t => t.agentId === agentId)?.id : undefined)
228 if (taskId !== undefined) {
229 await writeTasks(io, list =>
230 patch(list, taskId, task => ({ ...addUsage(task, step.tokens, step.costUsd), steps: task.steps + 1, milestones: Math.max(task.milestones, 3) })),
231 )
232 }
233 }
234 if (firstTool !== undefined) await setActivity(io, activityForTool(firstTool))
235}
236
237export type TurnEnd = {
238 turnId: string
239 agentId?: string
240 reason: 'answer' | 'aborted' | 'refusal' | 'error'
241 refusalCategory?: string | null
242 answer: string
243}
244
245export async function onTurnComplete(io: Io, end: TurnEnd): Promise<void> {
246 const answer = redactPaths(redactSecrets(end.answer)).replace(/\s+/g, ' ').slice(0, 160)
247 if (end.agentId !== undefined) {
248 const child = (await io.state.tasks.get()).find(t => t.agentId === end.agentId && isOpen(t))
249 if (child !== undefined) {
250 await finishTask(io, child.id, end.reason === 'answer' ? 'COMPLETED' : end.reason === 'aborted' ? 'CANCELLED' : 'FAILED', {
251 result: answer || undefined,
252 error: end.reason === 'answer' ? undefined : end.reason,
253 })
254 }
255 return
256 }
257 const run = runtime.turns.get(end.turnId)
258 runtime.turns.delete(end.turnId)
259 const config = await io.state.config.get()
260 if (run !== undefined) {
261 runtime.previous = { tier: run.tier, endedAt: await io.clock.now() }
262 if (end.reason === 'answer') {
263 if (config.modules.autoQa && config.qa.autoRun && run.hadEdits) {
264 await writeTasks(io, list => patch(list, run.taskId, task => ({ ...task, status: 'VALIDATING', milestones: 4, result: answer || undefined })))
265 scheduleQa(run.taskId)
266 } else {
267 await finishTask(io, run.taskId, 'COMPLETED', { result: answer || 'turno encerrado com resposta' })
268 }
269 } else if (end.reason === 'aborted') {
270 await finishTask(io, run.taskId, 'CANCELLED', {})
271 } else {
272 await finishTask(io, run.taskId, 'FAILED', { error: end.reason === 'refusal' ? `recusa: ${end.refusalCategory ?? 'sem categoria'}` : 'erro de API' })
273 }
274 }
275 await setActivity(io, activityForTasks(await io.state.tasks.get(), 'idle'))
276 await maybeRefreshGit(io)
277}
278
279export type SpawnInput = { description: string; prompt: string; subagentType: string; model?: string; parentModel: string; fork: boolean }
280
281/** agent.spawn, before: pick a model when the spawn named none; open a child task. */
282export async function beforeSpawn(io: Io, e: SpawnInput): Promise<{ taskId: string; model: string | null }> {
283 const config = await io.state.config.get()
284 const c = classify(`${e.description}\n${e.prompt}`)
285 const shouldRoute = config.routing.enabled && config.modules.smartBrain && config.routing.routeSubagents && e.model === undefined && !e.fork
286 const tier = shouldRoute ? subagentTier(config, e.subagentType, c) : (tierOfModel(e.model ?? e.parentModel) ?? 'sonnet')
287 const parent = (await io.state.tasks.get()).filter(t => t.kind === 'turn' && isOpen(t)).at(-1)
288 const id = newId()
289 const now = await io.clock.now()
290 const decision: BigBearDecision = {
291 tier,
292 model: shouldRoute ? config.routing.models[tier] : (e.model ?? e.parentModel),
293 category: c.category,
294 complexity: c.complexity,
295 risk: c.risk,
296 reasons: shouldRoute ? [`subagente ${e.subagentType}: ${tier.toUpperCase()}`] : [`subagente ${e.subagentType}: modelo definido pela chamada`],
297 files: c.files,
298 source: 'rules',
299 confidence: c.confidence,
300 }
301 await writeTasks(io, list => [
302 ...list,
303 newTask({ id, now, title: `${e.subagentType} — ${title(e.description, decision.category)}`, decision, routed: shouldRoute, etaMs: null, kind: 'subagent', parentId: parent?.id }),
304 ])
305 return { taskId: id, model: shouldRoute ? config.routing.models[tier] : null }
306}
307
308export async function afterSpawn(io: Io, taskId: string, result: { deny?: string; agentId?: string; model?: string }): Promise<void> {
309 if (result.deny !== undefined) {
310 await finishTask(io, taskId, 'FAILED', { error: result.deny })
311 return
312 }
313 await writeTasks(io, list =>
314 patch(list, taskId, task => ({
315 ...task,
316 agentId: result.agentId,
317 model: result.model ?? task.model,
318 status: 'RUNNING',
319 milestones: 2,
320 tier: tierOfModel(result.model) ?? task.tier,
321 })),
322 )
323}
324
325/** tool.call, before: counts and the avatar. Returns the task it belongs to. */
326export async function beforeTool(io: Io, tool: string, agentId: string | undefined): Promise<BigBearTask | undefined> {
327 const tasks = await io.state.tasks.get()
328 const task = agentId !== undefined ? tasks.find(t => t.agentId === agentId && isOpen(t)) : tasks.filter(t => t.kind === 'turn' && isOpen(t)).at(-1)
329 await setActivity(io, activityForTool(tool))
330 if (task !== undefined) {
331 await writeTasks(io, list =>
332 patch(list, task.id, t => ({ ...t, toolCalls: t.toolCalls + 1, status: t.status === 'RUNNING' ? 'WAITING' : t.status, pendingTool: tool })),
333 )
334 }
335 return task
336}
337
338/** tool.call, after: errors feed escalation; edits arm Auto QA. */
339export async function afterTool(io: Io, task: BigBearTask | undefined, tool: string, failed: boolean): Promise<void> {
340 if (task === undefined) return
341 await writeTasks(io, list =>
342 patch(list, task.id, t => ({ ...t, toolErrors: t.toolErrors + (failed ? 1 : 0), status: t.status === 'WAITING' ? 'RUNNING' : t.status, pendingTool: undefined })),
343 )
344 const run = task.turnId !== undefined ? runtime.turns.get(task.turnId) : undefined
345 if (run !== undefined) {
346 if (failed) run.toolErrors++
347 if (!failed && EDIT_TOOLS.test(tool)) run.hadEdits = true
348 }
349}
350hooks/src/config/defaults.ts 134 lines1import type { BigBearConfig, BigBearModuleId, BigBearTier } from '../../../types'
2
3export const TIERS: readonly BigBearTier[] = ['haiku', 'sonnet', 'opus']
4
5export const MODULES: readonly { id: BigBearModuleId; label: string }[] = [
6 { id: 'smartBrain', label: 'Smart Brain' },
7 { id: 'taskCommand', label: 'Task Command' },
8 { id: 'tokenIntelligence', label: 'Token Intelligence' },
9 { id: 'usageMonitor', label: 'Usage Monitor' },
10 { id: 'codeGuardian', label: 'Code Guardian' },
11 { id: 'autoQa', label: 'Auto QA' },
12 { id: 'bugHunter', label: 'Bug Hunter' },
13 { id: 'gitGuardian', label: 'Git Guardian' },
14 { id: 'contextManager', label: 'Context Manager' },
15 { id: 'devAnalytics', label: 'Dev Analytics' },
16 { id: 'showcase', label: 'Showcase' },
17]
18
19/**
20 * Prices in USD per million tokens. Input and output come from the
21 * Anthropic model table bundled with Claude Code 2.1.293 (cached
22 * 2026-10-06). Cache reads for Opus 5.5 and Sonnet 5.5 come from the same
23 * source; the Haiku cache read and every cache write are derived with the
24 * standard multipliers (read 0.1x input, 5-minute write 1.25x input).
25 * Update them with `/bigbear price <tier> <in> <out> <read> <write>`.
26 */
27export const DEFAULT_PRICING: BigBearConfig['pricing'] = {
28 baseline: 'sonnet',
29 table: {
30 haiku: { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 },
31 sonnet: { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 },
32 opus: { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 5 },
33 },
34 source:
35 'Anthropic model table (Claude Code 2.1.293 bundle, cached 2026-10-06); cache writes and Haiku cache read derived (1.25x / 0.1x input)',
36 updatedAt: '2026-10-06',
37}
38
39export const DEFAULT_CONFIG: BigBearConfig = {
40 version: 1,
41 routing: {
42 enabled: true,
43 mode: 'balanced',
44 manualTier: null,
45 blocked: [],
46 models: {
47 haiku: 'claude-haiku-5-5',
48 sonnet: 'claude-sonnet-5-5',
49 opus: 'claude-opus-5-5',
50 },
51 routeSubagents: true,
52 cacheAware: true,
53 aiClassifier: false,
54 escalation: { enabled: true, maxPerTurn: 1, toolErrorThreshold: 3 },
55 learnFromHistory: true,
56 },
57 modules: {
58 smartBrain: true,
59 taskCommand: true,
60 tokenIntelligence: true,
61 usageMonitor: true,
62 codeGuardian: true,
63 autoQa: true,
64 bugHunter: true,
65 gitGuardian: true,
66 contextManager: true,
67 devAnalytics: true,
68 showcase: true,
69 },
70 usage: { warn: 70, high: 85, critical: 95, autoEco: false },
71 pricing: DEFAULT_PRICING,
72 guardian: {
73 action: 'ask',
74 criticalPaths: [
75 '.env',
76 '.env.*',
77 'package.json',
78 'package-lock.json',
79 'pnpm-lock.yaml',
80 'yarn.lock',
81 'tsconfig*.json',
82 '.github/workflows/*',
83 '**/migrations/**',
84 'Dockerfile',
85 'docker-compose*.yml',
86 '*.sql',
87 ],
88 protectedBranches: ['main', 'master', 'production', 'prod', 'release'],
89 },
90 qa: { autoRun: false, profile: 'auto' },
91 context: { warnPercent: 80 },
92 ui: { autoOpenPane: true, showBand: true, notifyOnFinish: true, refreshMs: 1000 },
93}
94
95function isPlainObject(value: unknown): value is Record<string, unknown> {
96 return typeof value === 'object' && value !== null && !Array.isArray(value)
97}
98
99/**
100 * Lays `stored` over `base`, keeping only keys `base` knows and only values
101 * of the same kind, so an old or hand-edited store never breaks a load.
102 */
103export function mergeShape<T>(base: T, stored: unknown): T {
104 if (stored === undefined || stored === null) return base
105 if (Array.isArray(base)) return (Array.isArray(stored) ? stored : base) as T
106 if (isPlainObject(base)) {
107 if (!isPlainObject(stored)) return base
108 const out: Record<string, unknown> = {}
109 for (const key of Object.keys(base)) {
110 out[key] = mergeShape((base as Record<string, unknown>)[key], stored[key])
111 }
112 return out as T
113 }
114 if (base === null) return stored as T
115 return (typeof stored === typeof base ? stored : base) as T
116}
117
118export function loadConfig(stored: unknown, project?: unknown): BigBearConfig {
119 const global = mergeShape(DEFAULT_CONFIG, stored)
120 // A fresh copy: values handed to $.state are frozen by the host, and the
121 // defaults must never be mutated.
122 const merged = JSON.parse(JSON.stringify(project ? mergeShape(global, project) : global)) as BigBearConfig
123 // manualTier is nullable: mergeShape keeps any string, so check it here.
124 if (merged.routing.manualTier !== null && !TIERS.includes(merged.routing.manualTier)) {
125 merged.routing.manualTier = null
126 }
127 merged.routing.blocked = merged.routing.blocked.filter(tier => TIERS.includes(tier))
128 if (!['eco', 'balanced', 'god'].includes(merged.routing.mode)) merged.routing.mode = 'balanced'
129 if (!['ask', 'deny', 'warn'].includes(merged.guardian.action)) merged.guardian.action = 'ask'
130 merged.ui.refreshMs = Math.min(10_000, Math.max(250, merged.ui.refreshMs))
131 merged.routing.escalation.maxPerTurn = Math.min(2, Math.max(0, merged.routing.escalation.maxPerTurn))
132 return merged
133}
134hooks/src/core/runtime.ts 39 lines1import type { BigBearDecision, BigBearTier } from '../../../types'
2import type { HistoryRecord } from '../services/analytics'
3import type { Io } from '../services/io'
4
5/**
6 * The module's own working memory: what a hot reload may drop without harm.
7 * Anything a drawing reads lives in `$.state` instead (core/state.ts).
8 */
9export type TurnRun = {
10 taskId: string
11 tier: BigBearTier
12 routed: boolean
13 escalations: number
14 toolErrors: number
15 hadEdits: boolean
16}
17
18export const runtime = {
19 /** The session-wide Io (built from session.start's `$`), for work that outlives one dispatch. */
20 session: null as Io | null,
21 root: '',
22 history: [] as HistoryRecord[],
23 /** The task a submitted prompt created, waiting for its turn.start. */
24 pendingTaskId: null as string | null,
25 /** A routing the next prompt must take (Bug Hunter). */
26 forced: null as Pick<BigBearDecision, 'tier' | 'reasons' | 'source'> | null,
27 turns: new Map<string, TurnRun>(),
28 previous: null as { tier: BigBearTier; endedAt: number } | null,
29 /** Usage alerts already raised, by window, level and reset time. */
30 alerted: new Set<string>(),
31 lastGitAt: 0,
32 qaRunning: false,
33 nextId: 1,
34}
35
36export function newId(): string {
37 return String(runtime.nextId++)
38}
39hooks/src/core/tasks.ts 122 lines1import type { BigBearActivity, BigBearDecision, BigBearStatus, BigBearTask, BigBearTokens } from '../../../types'
2import { addTokens, emptyTokens } from '../services/pricing'
3
4/**
5 * Milestones, each reached by an event the engine raised, never guessed:
6 * 1 classified (prompt.submit), 2 started (turn.start or agent.spawn),
7 * 3 first model step answered (turn.step), 4 turn ended with an answer
8 * (turn.complete), 5 verified (QA passed, or no QA asked for).
9 */
10export const MILESTONES = 5
11
12export const DONE: readonly BigBearStatus[] = ['COMPLETED', 'FAILED', 'CANCELLED']
13
14export function isOpen(task: BigBearTask): boolean {
15 return !DONE.includes(task.status)
16}
17
18export function newTask(args: {
19 id: string
20 now: number
21 title: string
22 decision: BigBearDecision
23 routed: boolean
24 etaMs: number | null
25 kind?: BigBearTask['kind']
26 parentId?: string
27}): BigBearTask {
28 return {
29 id: args.id,
30 title: args.title,
31 kind: args.kind ?? 'turn',
32 parentId: args.parentId,
33 category: args.decision.category,
34 tier: args.decision.tier,
35 model: args.decision.model,
36 reasons: args.decision.reasons,
37 status: 'ANALYZING',
38 startedAt: args.now,
39 steps: 0,
40 toolCalls: 0,
41 toolErrors: 0,
42 tokens: emptyTokens(),
43 costUsd: 0,
44 escalations: 0,
45 milestones: 1,
46 etaMs: args.etaMs,
47 routed: args.routed,
48 }
49}
50
51export function patch(tasks: readonly BigBearTask[], id: string, change: (task: BigBearTask) => BigBearTask): BigBearTask[] {
52 return tasks.map(task => (task.id === id ? change(task) : task))
53}
54
55export function addUsage(task: BigBearTask, tokens: BigBearTokens, costUsd: number): BigBearTask {
56 return { ...task, tokens: addTokens(task.tokens, tokens), costUsd: task.costUsd + costUsd }
57}
58
59export function finish(task: BigBearTask, status: BigBearStatus, now: number, extra: Partial<BigBearTask> = {}): BigBearTask {
60 const milestones = status === 'COMPLETED' ? MILESTONES : task.milestones
61 return { ...task, ...extra, status, endedAt: now, milestones, pendingTool: undefined }
62}
63
64/** Progress as reached milestones over all; null while a step runs with no milestone ahead known. */
65export function progress(task: BigBearTask): number {
66 return Math.round((task.milestones / MILESTONES) * 100)
67}
68
69export type Overall = {
70 total: number
71 done: number
72 completed: number
73 failed: number
74 percent: number | null
75 /** Since the first task began, gaps included. */
76 elapsedMs: number
77 /** Time the agent actually spent on tasks: each task's own duration, summed. */
78 workedMs: number
79 /** How long the oldest open task has been running; null when none is. */
80 runningMs: number | null
81 etaMs: number | null
82}
83
84export function overall(tasks: readonly BigBearTask[], now: number): Overall {
85 const roots = tasks.filter(task => task.kind === 'turn')
86 const done = roots.filter(task => !isOpen(task)).length
87 const completed = roots.filter(task => task.status === 'COMPLETED').length
88 const failed = roots.filter(task => task.status === 'FAILED').length
89 const first = roots.reduce((min, task) => Math.min(min, task.startedAt), Number.POSITIVE_INFINITY)
90 const elapsedMs = Number.isFinite(first) ? now - first : 0
91 const open = roots.filter(isOpen)
92 const workedMs = roots.reduce((sum, task) => sum + Math.max(0, (task.endedAt ?? now) - task.startedAt), 0)
93 const runningMs = open.length === 0 ? null : Math.max(0, now - Math.min(...open.map(task => task.startedAt)))
94 // An estimate exists only when every open task has one from history.
95 const etaMs =
96 open.length === 0
97 ? 0
98 : open.every(task => task.etaMs !== null)
99 ? open.reduce((sum, task) => sum + Math.max(0, (task.etaMs ?? 0) - (now - task.startedAt)), 0)
100 : null
101 return { total: roots.length, done, completed, failed, percent: roots.length === 0 ? null : Math.round((done / roots.length) * 100), elapsedMs, workedMs, runningMs, etaMs }
102}
103
104const READ_TOOLS = /^(Read|Grep|Glob|LS|WebFetch|WebSearch|ToolSearch|NotebookRead|LSP|ListMcpResourcesTool|ReadMcpResourceTool|TaskOutput|AskUserQuestion)$/
105const READ_MCP = /(read|get|list|search|fetch|query|find|status|view)/i
106
107/** What a tool call says the session is doing, for the avatar. */
108export function activityForTool(tool: string): BigBearActivity {
109 if (READ_TOOLS.test(tool)) return 'research'
110 if (tool.startsWith('mcp__')) return READ_MCP.test(tool.split('__').at(-1) ?? '') ? 'research' : 'work'
111 if (tool === 'Agent' || tool === 'Task') return 'research'
112 return 'work'
113}
114
115/** Activity from the task list alone: idle when nothing is open. */
116export function activityForTasks(tasks: readonly BigBearTask[], last: BigBearActivity): BigBearActivity {
117 const open = tasks.filter(isOpen)
118 if (open.length === 0) return 'idle'
119 if (open.some(task => task.status === 'VALIDATING')) return 'work'
120 return last === 'idle' ? 'research' : last
121}
122hooks/src/modules/guard-hooks.ts 81 lines1import type { Io } from '../services/io'
2
3import { alert } from '../services/engine'
4import type { Finding } from './guardian'
5import { analyzeCommand, findSecrets, isGitCommit, isPushFromProtected, matchCritical, worst } from './guardian'
6
7
8const ALLOW = 'Permitir esta vez'
9const BLOCK = 'Bloquear'
10
11/**
12 * Asks the person, in the engine's own dialog, before a dangerous call goes
13 * on to the engine's permission check. Never allows anything the engine
14 * would refuse: a "yes" here only lets the call reach that check.
15 */
16async function gate(io: Io, label: string, findings: Finding[]): Promise<{ deny: string } | null> {
17 const config = await io.state.config.get()
18 const level = worst(findings)
19 if (level === null) return null
20 const summary = findings.map(f => f.text).join('; ')
21 await alert(io, 'codeGuardian', level === 'warn' ? 'warn' : 'danger', `${label}: ${summary}`)
22 if (level === 'block') return { deny: `BIG BEAR Code Guardian bloqueou: ${summary}. Operação irreversível: rode você mesmo, se for intencional.` }
23 if (level === 'warn' || config.guardian.action === 'warn') {
24 io.ui.toast(`🐻 Guardian: ${summary}`)
25 return null
26 }
27 if (config.guardian.action === 'deny') return { deny: `BIG BEAR Code Guardian: ${summary}. Bloqueado pela política (Settings → Comando perigoso).` }
28 try {
29 const answer = await io.ui.ask(`🐻 Code Guardian: ${summary}. Deixar seguir para a aprovação normal?`, { options: [ALLOW, BLOCK], header: 'Guardian' })
30 return answer === ALLOW ? null : { deny: `BIG BEAR Code Guardian: bloqueado pelo usuário (${summary}).` }
31 } catch {
32 return { deny: `BIG BEAR Code Guardian: ${summary}. Sem ninguém para confirmar, a operação foi bloqueada.` }
33 }
34}
35
36function commandFindings(command: string, protectedBranches: string[], branch: string | null, qaFailed: boolean): Finding[] {
37 const findings = analyzeCommand(command, protectedBranches)
38 if (isPushFromProtected(command, branch, protectedBranches) && !findings.some(f => f.rule === 'protected-branch-push')) {
39 findings.push({ severity: 'ask', rule: 'protected-branch-push', text: `push a partir da branch protegida ${branch}` })
40 }
41 if (qaFailed && isGitCommit(command)) findings.push({ severity: 'ask', rule: 'commit-failed-qa', text: 'commit com o último Auto QA em falha' })
42 return findings
43}
44
45export type Refusal = { deny: string } | null
46
47/** Code Guardian and Git Guardian on a shell command (Bash, PowerShell). */
48export async function guardShell(io: Io, tool: string, command: string): Promise<Refusal> {
49 const config = await io.state.config.get()
50 if (!config.modules.codeGuardian && !config.modules.gitGuardian) return null
51 const git = await io.state.git.get()
52 const qa = await io.state.qa.get()
53 let findings = commandFindings(command, config.guardian.protectedBranches, git?.branch ?? null, qa.lastResult === 'failed')
54 const isGitRule = (f: Finding) => f.rule.startsWith('git-') || f.rule.includes('branch') || f.rule === 'commit-failed-qa'
55 if (!config.modules.gitGuardian) findings = findings.filter(f => !isGitRule(f))
56 if (!config.modules.codeGuardian) findings = findings.filter(isGitRule)
57 return gate(io, tool, findings)
58}
59
60/** When the guard itself failed: refuse only what the rules alone call irreversible. */
61export function shellFallback(command: string): Refusal {
62 return worst(analyzeCommand(command)) === 'block' ? { deny: 'BIG BEAR Code Guardian: falhou ao checar um comando irreversível.' } : null
63}
64
65export type EditInput = { file_path?: string; content?: string; new_string?: string; edits?: { new_string?: string }[] }
66
67/** Code Guardian on a file edit: secrets being written, critical paths. */
68export async function guardEdit(io: Io, tool: string, input: EditInput): Promise<Refusal> {
69 const config = await io.state.config.get()
70 if (!config.modules.codeGuardian) return null
71 const path = input.file_path ?? ''
72 const text = [input.content, input.new_string, ...(input.edits ?? []).map(edit => edit.new_string)].filter(Boolean).join('\n')
73 const findings: Finding[] = findSecrets(text).map(rule => ({ severity: 'ask' as const, rule: `secret:${rule}`, text: `possível segredo (${rule}) sendo gravado` }))
74 const critical = matchCritical(path, config.guardian.criticalPaths)
75 if (critical !== null) {
76 await alert(io, 'codeGuardian', 'warn', `arquivo crítico alterado (${critical}): revisar antes do commit`)
77 io.ui.toast(`🐻 Guardian: arquivo crítico (${critical}) — revise antes do commit`)
78 }
79 return gate(io, tool, findings)
80}
81hooks/src/modules/monitor.ts 62 lines1import type { SessionMeasureInput } from 'claude-code'
2import type { Io } from '../services/io'
3
4import type { BigBearUsage } from '../../../types'
5import { runtime } from '../core/runtime'
6import { alert, saveSettings, usageSnapshot } from '../services/engine'
7
8type Level = { at: number; level: 'info' | 'warn' | 'danger'; text: string }
9
10/** Which thresholds a window has crossed, highest first. */
11export function crossed(percent: number, warn: number, high: number, critical: number): Level | null {
12 if (percent >= critical) return { at: critical, level: 'danger', text: 'limite próximo' }
13 if (percent >= high) return { at: high, level: 'danger', text: 'consumo elevado' }
14 if (percent >= warn) return { at: warn, level: 'warn', text: 'aviso preventivo' }
15 return null
16}
17
18/**
19 * session.measure: Usage Monitor and Context Manager read the engine's own
20 * measurements, the account's rate-limit windows and the live context window.
21 */
22export async function onMeasure(io: Io, e: SessionMeasureInput): Promise<void> {
23 {
24 const config = await io.state.config.get()
25 const now = await io.clock.now()
26 const snapshot: BigBearUsage = usageSnapshot(now, e)
27 await io.state.usage.set(() => snapshot)
28
29 if (config.modules.usageMonitor) {
30 const windows = [
31 { name: '5H', value: snapshot.fiveHour },
32 { name: 'SEMANA', value: snapshot.week },
33 ]
34 for (const window of windows) {
35 if (window.value === null) continue
36 const hit = crossed(window.value.percent, config.usage.warn, config.usage.high, config.usage.critical)
37 if (hit === null) continue
38 const key = `${window.name}:${hit.at}:${window.value.resetsAt ?? ''}`
39 if (runtime.alerted.has(key)) continue
40 runtime.alerted.add(key)
41 const advice = config.routing.mode === 'eco' ? '' : ' · considere /bigbear mode eco'
42 await alert(io, 'usageMonitor', hit.level, `${window.name} ${Math.round(window.value.percent)}%: ${hit.text}${advice}`)
43 io.ui.toast(`🐻 ${window.name} ${Math.round(window.value.percent)}% — ${hit.text}${advice}`)
44 if (config.usage.autoEco && hit.at >= config.usage.high && config.routing.mode !== 'eco') {
45 await saveSettings(io, { ...config, routing: { ...config.routing, mode: 'eco' } })
46 io.ui.toast('🐻 ECO MODE ativado automaticamente (Settings → ECO automático)')
47 }
48 }
49 }
50
51 if (config.modules.contextManager && snapshot.contextPercent !== null && snapshot.contextPercent >= config.context.warnPercent) {
52 const bucket = Math.floor(snapshot.contextPercent / 5) * 5
53 const key = `ctx:${bucket}`
54 if (!runtime.alerted.has(key)) {
55 runtime.alerted.add(key)
56 await alert(io, 'contextManager', 'warn', `contexto em ${snapshot.contextPercent}%: considere /compact (o BIG BEAR não compacta sozinho)`)
57 io.ui.toast(`🐻 Contexto ${snapshot.contextPercent}% — considere /compact`)
58 }
59 }
60 }
61}
62hooks/src/services/engine.ts 199 lines1import type { SessionRateLimit, SessionUsage } from 'claude-code'
2import type { Io } from './io'
3
4import type { BigBearAlert, BigBearConfig, BigBearGit, BigBearModuleId, BigBearUsage } from '../../../types'
5import { DEFAULT_CONFIG, loadConfig } from '../config/defaults'
6import { MAX_ALERTS } from '../core/state'
7import { notARepo, parseStatus } from '../modules/git'
8import { redactSecrets } from '../modules/guardian'
9import type { ProjectFacts, QaPlan } from '../modules/qa'
10import { tail } from '../modules/qa'
11import type { HistoryRecord } from './analytics'
12
13/**
14 * Everything that touches the engine for the mod's services: the durable
15 * store, git, the project's files and the QA runs. Hooks call these; the
16 * rules they apply live in pure modules.
17 */
18
19/** The longest a QA step may run: the engine's own ceiling. */
20export const QA_STEP_TIMEOUT_MS = 10 * 60 * 1000
21
22const STORE_CONFIG = 'config'
23const STORE_HISTORY = 'history'
24const projectKey = (root: string) => `project:${root.toLowerCase()}`
25const decisionsKey = (root: string) => `decisions:${root.toLowerCase()}`
26
27export async function loadSettings(io: Io, root: string): Promise<BigBearConfig> {
28 const [global, project] = await Promise.all([io.store.get(STORE_CONFIG), io.store.get(projectKey(root))])
29 return loadConfig(global, project)
30}
31
32/** Saves the global settings, or this project's policy on top of them. */
33export async function saveSettings(io: Io, config: BigBearConfig, scope: 'global' | 'project' = 'global'): Promise<void> {
34 const root = await io.state.project.get()
35 if (scope === 'project') {
36 await io.store.set(projectKey(root), { routing: { mode: config.routing.mode, manualTier: config.routing.manualTier, blocked: config.routing.blocked } })
37 } else {
38 await io.store.set(STORE_CONFIG, config)
39 }
40 await io.state.config.set(() => config)
41}
42
43export async function resetSettings(io: Io): Promise<BigBearConfig> {
44 const root = await io.state.project.get()
45 await io.store.delete(STORE_CONFIG)
46 await io.store.delete(projectKey(root))
47 await io.state.config.set(() => DEFAULT_CONFIG)
48 return DEFAULT_CONFIG
49}
50
51export async function hasProjectPolicy(io: Io): Promise<boolean> {
52 const root = await io.state.project.get()
53 return (await io.store.get(projectKey(root))) !== undefined
54}
55
56export async function clearProjectPolicy(io: Io): Promise<void> {
57 const root = await io.state.project.get()
58 await io.store.delete(projectKey(root))
59 const config = loadConfig(await io.store.get(STORE_CONFIG))
60 await io.state.config.set(() => config)
61}
62
63export async function loadHistory(io: Io): Promise<HistoryRecord[]> {
64 const stored = await io.store.get(STORE_HISTORY)
65 return Array.isArray(stored) ? (stored as HistoryRecord[]) : []
66}
67
68export async function saveHistory(io: Io, history: HistoryRecord[]): Promise<void> {
69 await io.store.set(STORE_HISTORY, history)
70}
71
72export type DecisionNote = { at: number; text: string }
73
74export async function loadDecisions(io: Io, root: string): Promise<DecisionNote[]> {
75 const stored = await io.store.get(decisionsKey(root))
76 return Array.isArray(stored) ? (stored as DecisionNote[]) : []
77}
78
79export async function addDecision(io: Io, root: string, text: string, now: number): Promise<DecisionNote[]> {
80 const list = [...(await loadDecisions(io, root)), { at: now, text: redactSecrets(text).slice(0, 500) }].slice(-100)
81 await io.store.set(decisionsKey(root), list)
82 return list
83}
84
85export async function alert(io: Io, module: BigBearModuleId, level: BigBearAlert['level'], text: string): Promise<void> {
86 const at = await io.clock.now()
87 await io.state.alerts.set(list => [...list, { at, level, module, text }].slice(-MAX_ALERTS))
88}
89
90function windowOf(limits: readonly SessionRateLimit[], kind: string): BigBearUsage['fiveHour'] {
91 const found = limits.find(limit => limit.kind === kind)
92 return found === undefined ? null : { percent: found.percentUsed, resetsAt: found.resetsAt }
93}
94
95export function usageSnapshot(at: number, usage: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): BigBearUsage {
96 return {
97 at,
98 fiveHour: windowOf(usage.rateLimits, 'five_hour'),
99 week: windowOf(usage.rateLimits, 'seven_day'),
100 contextPercent: usage.context.percent ?? null,
101 contextTokens: usage.context.tokens ?? null,
102 contextWindow: usage.context.window ?? null,
103 sessionUsd: usage.cost?.usd ?? null,
104 }
105}
106
107export async function refreshGit(io: Io, root: string): Promise<BigBearGit> {
108 const at = await io.clock.now()
109 let git: BigBearGit
110 try {
111 const status = await io.process.run(['git', 'status', '--porcelain=v1', '--branch'], { cwd: root, timeoutMs: 10_000 })
112 if (status.exitCode !== 0) {
113 git = notARepo(at)
114 } else {
115 git = parseStatus(status.stdout, at)
116 const [log, merge] = await Promise.all([
117 io.process.run(['git', 'log', '-1', '--format=%h %s (%cr)'], { cwd: root, timeoutMs: 10_000 }),
118 io.process.run(['git', 'rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd: root, timeoutMs: 10_000 }),
119 ])
120 git.lastCommit = log.exitCode === 0 ? redactSecrets(log.stdout.trim()).slice(0, 120) || null : null
121 git.merging = merge.exitCode === 0
122 }
123 } catch {
124 git = notARepo(at)
125 }
126 await io.state.git.set(() => git)
127 return git
128}
129
130export async function projectFacts(io: Io, root: string): Promise<ProjectFacts> {
131 const sep = root.includes('\\') ? '\\' : '/'
132 const path = (name: string) => `${root}${root.endsWith(sep) ? '' : sep}${name}`
133 let entries: string[] = []
134 try {
135 entries = (await io.fs.list(root)).map(entry => entry.name)
136 } catch {
137 entries = []
138 }
139 let packageName: string | null = null
140 let scripts: Record<string, string> = {}
141 if (entries.includes('package.json')) {
142 try {
143 const pkg = JSON.parse(await io.fs.read(path('package.json'))) as { name?: unknown; scripts?: unknown }
144 packageName = typeof pkg.name === 'string' ? pkg.name : null
145 if (typeof pkg.scripts === 'object' && pkg.scripts !== null) {
146 scripts = Object.fromEntries(Object.entries(pkg.scripts).filter((pair): pair is [string, string] => typeof pair[1] === 'string'))
147 }
148 } catch {
149 // A broken package.json leaves no scripts to run.
150 }
151 }
152 const hasLockfile = entries.includes('pnpm-lock.yaml')
153 ? 'pnpm'
154 : entries.includes('yarn.lock')
155 ? 'yarn'
156 : entries.includes('bun.lockb') || entries.includes('bun.lock')
157 ? 'bun'
158 : entries.includes('package-lock.json')
159 ? 'npm'
160 : null
161 return { packageName, scripts, entries, isWindows: /^[A-Za-z]:[\\/]/.test(root), hasLockfile }
162}
163
164/** Runs a QA plan for real, step by step, writing each result as it lands. */
165export async function runQa(io: Io, root: string, plan: QaPlan): Promise<'passed' | 'failed' | null> {
166 const startedAt = await io.clock.now()
167 await io.state.qa.set(() => ({ profile: plan.profile, startedAt, endedAt: null, steps: plan.steps, lastResult: null }))
168 if (plan.steps.length === 0) {
169 await io.state.qa.set(qa => ({ ...qa, endedAt: startedAt }))
170 return null
171 }
172 let failed = false
173 for (let index = 0; index < plan.steps.length; index++) {
174 const step = plan.steps[index]!
175 await io.state.qa.set(qa => ({ ...qa, steps: qa.steps.map((s, i) => (i === index ? { ...s, status: 'running' as const } : s)) }))
176 const began = await io.clock.now()
177 let exitCode = 1
178 let output = ''
179 try {
180 const ran = await io.process.run(step.argv, { cwd: root, env: { CI: '1', FORCE_COLOR: '0' }, timeoutMs: QA_STEP_TIMEOUT_MS })
181 exitCode = ran.exitCode
182 output = `${ran.stdout}\n${ran.stderr}`
183 } catch (error) {
184 output = error instanceof Error ? error.message : String(error)
185 }
186 const durationMs = (await io.clock.now()) - began
187 const status: 'passed' | 'failed' = exitCode === 0 ? 'passed' : 'failed'
188 if (status === 'failed') failed = true
189 await io.state.qa.set(qa => ({
190 ...qa,
191 steps: qa.steps.map((s, i) => (i === index ? { ...s, status, exitCode, durationMs, tail: redactSecrets(tail(output)) } : s)),
192 }))
193 }
194 const endedAt = await io.clock.now()
195 const lastResult: 'passed' | 'failed' = failed ? 'failed' : 'passed'
196 await io.state.qa.set(qa => ({ ...qa, endedAt, lastResult }))
197 return lastResult
198}
199hooks/src/services/io.ts 28 lines1import type { EngineInterface, PluginState } from 'claude-code'
2
3/**
4 * The engine's capabilities the modules use, as plain closures.
5 *
6 * The engine follows `$` only into functions of the file that received it,
7 * so hooks/register.tsx (the one place events are captured) builds this
8 * object from its `$` and hands it to the modules. The modules hold the
9 * rules; this object is their only door to the engine.
10 */
11/** One of the mod's session values: read (which subscribes a drawing) and change. */
12export type Cell<T> = { get: () => Promise<T>; set: (change: (value: T) => T) => Promise<T> }
13
14type BigBearState = PluginState['big-bear']
15
16export type Io = {
17 state: { [K in keyof BigBearState]: Cell<BigBearState[K]> }
18 clock: Pick<EngineInterface['clock'], 'now' | 'after' | 'every'>
19 ui: Pick<EngineInterface['ui'], 'toast' | 'log' | 'open' | 'close' | 'panes' | 'ask' | 'copy'>
20 store: EngineInterface['store']
21 process: Pick<EngineInterface['process'], 'run'>
22 fs: { list: EngineInterface['fs']['list']; read: (path: string) => Promise<string> }
23 session: Pick<EngineInterface['session'], 'root' | 'model' | 'usage' | 'version' | 'surfaces'>
24 command: Pick<EngineInterface['command'], 'list' | 'register'>
25 model: Pick<EngineInterface['model'], 'complete' | 'classify'>
26 prompt: Pick<EngineInterface['prompt'], 'submit'>
27}
28hooks/src/services/pricing.ts 152 lines1import type {
2 BigBearConfig,
3 BigBearMetrics,
4 BigBearModelBucket,
5 BigBearPrice,
6 BigBearTier,
7 BigBearTokens,
8} from '../../../types'
9import { TIERS } from '../config/defaults'
10import { tierOfModel } from '../core/router'
11
12/** A turn's usage as the engine reports it (ModelUsage plus the model). */
13export type ApiUsage = {
14 input_tokens: number
15 output_tokens: number
16 cache_read_input_tokens: number
17 cache_creation_input_tokens: number
18 model: string
19}
20
21export function toTokens(usage: ApiUsage): BigBearTokens {
22 return {
23 input: usage.input_tokens ?? 0,
24 output: usage.output_tokens ?? 0,
25 cacheRead: usage.cache_read_input_tokens ?? 0,
26 cacheWrite: usage.cache_creation_input_tokens ?? 0,
27 }
28}
29
30export function emptyTokens(): BigBearTokens {
31 return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
32}
33
34export function addTokens(a: BigBearTokens, b: BigBearTokens): BigBearTokens {
35 return {
36 input: a.input + b.input,
37 output: a.output + b.output,
38 cacheRead: a.cacheRead + b.cacheRead,
39 cacheWrite: a.cacheWrite + b.cacheWrite,
40 }
41}
42
43export function totalTokens(t: BigBearTokens): number {
44 return t.input + t.output + t.cacheRead + t.cacheWrite
45}
46
47/** API-equivalent cost in USD: not what a subscription bills. */
48export function costOf(tokens: BigBearTokens, price: BigBearPrice): number {
49 return (
50 (tokens.input * price.input +
51 tokens.output * price.output +
52 tokens.cacheRead * price.cacheRead +
53 tokens.cacheWrite * price.cacheWrite) /
54 1_000_000
55 )
56}
57
58export function emptyBucket(): BigBearModelBucket {
59 return { ...emptyTokens(), steps: 0, costUsd: 0 }
60}
61
62export function emptyMetrics(): BigBearMetrics {
63 return {
64 byTier: { haiku: emptyBucket(), sonnet: emptyBucket(), opus: emptyBucket() },
65 other: {},
66 baselineUsd: 0,
67 escalations: 0,
68 }
69}
70
71export type PricedStep = { tier: BigBearTier | null; tokens: BigBearTokens; costUsd: number; baselineUsd: number }
72
73export function priceStep(config: BigBearConfig, usage: ApiUsage): PricedStep {
74 const tokens = toTokens(usage)
75 const tier = tierOfModel(usage.model)
76 const table = config.pricing.table
77 const costUsd = tier === null ? 0 : costOf(tokens, table[tier])
78 const baselineUsd = costOf(tokens, table[config.pricing.baseline])
79 return { tier, tokens, costUsd, baselineUsd }
80}
81
82export function addStep(metrics: BigBearMetrics, model: string, step: PricedStep): BigBearMetrics {
83 const bump = (bucket: BigBearModelBucket): BigBearModelBucket => ({
84 ...addTokens(bucket, step.tokens),
85 steps: bucket.steps + 1,
86 costUsd: bucket.costUsd + step.costUsd,
87 })
88 if (step.tier === null) {
89 return {
90 ...metrics,
91 other: { ...metrics.other, [model]: bump(metrics.other[model] ?? emptyBucket()) },
92 // An unpriced model adds nothing to either side of the comparison.
93 }
94 }
95 return {
96 ...metrics,
97 byTier: { ...metrics.byTier, [step.tier]: bump(metrics.byTier[step.tier]) },
98 baselineUsd: metrics.baselineUsd + step.baselineUsd,
99 }
100}
101
102export function sessionTokens(metrics: BigBearMetrics): BigBearTokens {
103 let sum = emptyTokens()
104 for (const tier of TIERS) sum = addTokens(sum, metrics.byTier[tier])
105 for (const bucket of Object.values(metrics.other)) sum = addTokens(sum, bucket)
106 return sum
107}
108
109export function sessionCost(metrics: BigBearMetrics): number {
110 return TIERS.reduce((sum, tier) => sum + metrics.byTier[tier].costUsd, 0)
111}
112
113export type Savings =
114 | { isAvailable: true; observedUsd: number; baselineUsd: number; savedUsd: number; percent: number; tokensSaved: number }
115 | { isAvailable: false; reason: string }
116
117/**
118 * Estimated savings against a run of the same tokens on the baseline tier.
119 * An estimate: a different model would not have spent the same tokens.
120 *
121 * `tokensSaved` is the saved share of the baseline cost expressed in tokens:
122 * the session's tokens times that share. Routing does not make a request
123 * smaller, it makes it cheaper, so this reads as "tokens of the baseline
124 * model's bill that were not spent", never as tokens the account was refunded.
125 */
126export function savings(metrics: BigBearMetrics): Savings {
127 const observedUsd = sessionCost(metrics)
128 const steps = TIERS.reduce((sum, tier) => sum + metrics.byTier[tier].steps, 0)
129 if (steps < 1 || metrics.baselineUsd <= 0) return { isAvailable: false, reason: 'Estimativa indisponível' }
130 const savedUsd = metrics.baselineUsd - observedUsd
131 const percent = (savedUsd / metrics.baselineUsd) * 100
132 const tokensSaved = percent > 0 ? Math.round((totalTokens(sessionTokens(metrics)) * percent) / 100) : 0
133 return { isAvailable: true, observedUsd, baselineUsd: metrics.baselineUsd, savedUsd, percent, tokensSaved }
134}
135
136/** Share of tokens per tier, as whole percents summing to 100, or null with no data. */
137export function distribution(metrics: BigBearMetrics): Record<BigBearTier, number> | null {
138 const totals = TIERS.map(tier => totalTokens(metrics.byTier[tier]))
139 const sum = totals.reduce((a, b) => a + b, 0)
140 if (sum === 0) return null
141 const raw = totals.map(value => (value / sum) * 100)
142 const floors = raw.map(Math.floor)
143 let left = 100 - floors.reduce((a, b) => a + b, 0)
144 const order = raw.map((value, index) => ({ index, frac: value - Math.floor(value) })).sort((a, b) => b.frac - a.frac)
145 for (const { index } of order) {
146 if (left <= 0) break
147 floors[index]! += 1
148 left -= 1
149 }
150 return { haiku: floors[0]!, sonnet: floors[1]!, opus: floors[2]! }
151}
152hooks/src/ui/band.tsx 87 lines1
2import type { BigBearTier } from '../../../types'
3import { isOpen } from '../core/tasks'
4import { bar, PALETTE, TIER_COLOR, untilReset } from '../utils/format'
5import type { Kit } from './kit'
6import { ACTIVITY_COLOR, ACTIVITY_LABEL, meterColor, pill } from './kit'
7import type { Io } from '../services/io'
8
9
10const MODE_COLOR = { eco: PALETTE.primary, balanced: PALETTE.secondary, god: PALETTE.accent } as const
11
12/** The usage band above the prompt: account windows, mode, model. */
13export async function renderBand(ui: Kit, io: Io, width: number) {
14 const [config, usage, tasks, decision, activity] = await Promise.all([
15 io.state.config.get(),
16 io.state.usage.get(),
17 io.state.tasks.get(),
18 io.state.decision.get(),
19 io.state.activity.get(),
20 ])
21 const now = await io.clock.now()
22 const running = tasks.find(task => isOpen(task) && task.kind === 'turn')
23 const tier: BigBearTier | null = running?.tier ?? decision?.tier ?? null
24 const { warn, high } = config.usage
25 const meterWidth = width >= 110 ? 10 : width >= 80 ? 6 : 4
26 const isTwoLines = width < 72
27
28
29 const meter = (label: string, value: { percent: number } | null) =>
30 value === null ? (
31 <ui.Text color={PALETTE.muted}>{label} N/D — indisponível</ui.Text>
32 ) : (
33 <ui.Text color={meterColor(value.percent, warn, high)}>
34 {label} {bar(value.percent, meterWidth)} {Math.round(value.percent)}%
35 </ui.Text>
36 )
37
38 const identity = (
39 <ui.Box flexDirection="row" columnGap={1}>
40 <ui.Text color={ACTIVITY_COLOR[activity]} bold>
41 ●
42 </ui.Text>
43 <ui.Text color={PALETTE.text} bold>
44 BIG BEAR
45 </ui.Text>
46 {pill(ui, config.routing.mode.toUpperCase(), MODE_COLOR[config.routing.mode])}
47 {tier !== null && config.routing.enabled && pill(ui, tier.toUpperCase(), TIER_COLOR[tier])}
48 {!config.routing.enabled && pill(ui, 'AUTO OFF', PALETTE.warning)}
49 {width >= 90 && <ui.Text color={ACTIVITY_COLOR[activity]}>{ACTIVITY_LABEL[activity]}</ui.Text>}
50 </ui.Box>
51 )
52
53 const meters = (
54 <ui.Box flexDirection="row" gap={1}>
55 {meter('5H', usage?.fiveHour ?? null)}
56 <ui.Text color={PALETTE.muted}>│</ui.Text>
57 {meter('WEEK', usage?.week ?? null)}
58 {usage?.fiveHour?.resetsAt !== undefined && (
59 <ui.Text color={PALETTE.muted}>│ RESET 5H {untilReset(usage.fiveHour.resetsAt, now)}</ui.Text>
60 )}
61 {usage?.contextPercent !== null && usage?.contextPercent !== undefined && width >= 96 && (
62 <ui.Text color={meterColor(usage.contextPercent, config.context.warnPercent - 10, config.context.warnPercent)}>
63 │ CTX {usage.contextPercent}%
64 </ui.Text>
65 )}
66 {usage?.fiveHour !== null && usage?.fiveHour !== undefined && usage.fiveHour.percent >= high && (
67 <ui.Text color={PALETTE.danger} bold>
68 ▲ CONSUMO ELEVADO
69 </ui.Text>
70 )}
71 </ui.Box>
72 )
73
74 return isTwoLines ? (
75 <ui.Box flexDirection="column">
76 {identity}
77 {meters}
78 </ui.Box>
79 ) : (
80 <ui.Box flexDirection="row" gap={1}>
81 {identity}
82 <ui.Text color={PALETTE.muted}>│</ui.Text>
83 {meters}
84 </ui.Box>
85 )
86}
87