SLOPSHOPPER

big-bear

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

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · big-bear
│ ┃ bigbear ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ ╭─────────────────────────────────────────── │ big-bear │ │ ┃ │ ▄▄ BIG BEAR DEV ● Ocioso ⏺ Read(src/auth.ts) │ 🐻 ✓ Tarefa concluída · SONNET · Correção │ │ ┃ │ ▀▀▀▀▀▀▀▀▀▀ app ⎿ Read 6 lines │ · fix the failing auth test and add an │ │ ┃ │ ▄▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ audit log c… · 0s │ │ ┃ │ ▀▀▀▀▀▀▀▀▀ BALANCED SONNET ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ │ ▀▀▀▀▀▀▀▀▀▄ ⏺ Bash(bun test) │ ┃ │ ▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ ╰─────────────────────────────────────────── │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▍ TASKS ──────────────────────────────────── │ ┃ 0 EXECUTANDO 1 CONCLUÍDAS ✻ Worked for 42s · done 4:20 PM │ ┃ AGORA ocioso TRAB. 0s RESTA — │ ┃ › /bigbear │ ┃ ▌ ✓ S Correção · fix the failing auth… ▰▰▰▰ ⎿ big-bear: 🐻 Central fechada. │ ┃ │ ┃ ▍ TOKENS ─────────────────────────────────── │ ┃ 0 tok $0.00 eq. API econ. N/D │ ┃ uso por modelo: sem dados │ ┃ │ ┃ ▍ GUARDIANS ──────────────────────────────── │ ┃ ● Git merge em curso ● QA não execu │ ┃ ⚠ Bash: push forçado reescreve o histórico r │ ┃ │ ┃ [ ECO ] [ BAL ] [ GOD ] [ Auto ON ] [ Set ] │ ● BIG BEAR BALANCED SONNET Ocioso │ 5H ██░░░░ 31% │ WEEK N/D — indisponível │ RESET 5H N/D │ CTX 49% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● BIG BEAR BALANCED SONNET Ocioso │ 5H ██░░░░ 31% │ WEEK N/D — indisponível │ RESET 5H N/D │ CTX
Pane · bigbear
╭──────────────────────────────────────────────────────╮ │ ▄▄ BIG BEAR DEV ● Ocioso │ │ ▀▀▀▀▀▀▀▀▀▀ app │ │ ▄▀▀▀▀▀▀▀▀▀ │ │ ▀▀▀▀▀▀▀▀▀ BALANCED SONNET │ │ ▀▀▀▀▀▀▀▀▀▄ │ │ ▀▀▀▀▀▀▀▀▀▀ │ ╰──────────────────────────────────────────────────────╯ ▍ TASKS ──────────────────────────────────────────────────── 0 EXECUTANDO 1 CONCLUÍDAS AGORA ocioso TRAB. 0s RESTA — ▌ ✓ S Correção · fix the failing auth… ▰▰▰▰▰ 0s !1 ▸ ▍ TOKENS ─────────────────────────────────────────────────── 0 tok $0.00 eq. API econ. N/D uso por modelo: sem dados ▍ GUARDIANS ──────────────────────────────────────────────── ● Git merge em curso ● QA não executado ● S ⚠ Bash: push forçado reescreve o histórico remoto; re… [ ECO ] [ BAL ] [ GOD ] [ Auto ON ] [ Set ] [ Show ]
Pane · bigbear-settings
🐻 BIG BEAR · SETTINGS Salvo entre sessões (configuração global). ▍ MÓDULOS ────────────────────────────────────────────────── [ ● Smart Brain ] [ ● Task Command ] [ ● Token Intelligence ▍ SMART BRAIN ────────────────────────────────────────────── [ ● Roteamento automático ] [ ○ ECO ] [ ● BALANCED ] [ ○ GOD Modelo: [ ● Auto ] [ ○ HAIKU ] [ ○ SONNET ] [ ○ OPUS ] Bloquear: [ ○ HAIKU ] [ ○ SONNET ] [ ○ OPUS ] [ ● Rotear subagentes ] [ ● Preservar cache ] [ ● Escaloname HAIKU claude-haiku-5-5 SONNET claude-sonnet-5-5 OPUS claude-opus-5-5 ▍ CONSUMO E CONTEXTO ─────────────────────────────────────── Alertas 70% / 85% / 95% · contexto 80% [ ○ ECO automático no Referência p/ economia: [ ○ HAIKU ] [ ● SONNET ] [ ○ OPUS ] ▍ PROTEÇÃO E QA ──────────────────────────────────────────── Comando perigoso: [ ● Perguntar ] [ ○ Bloquear ] [ ○ Só avis [ ○ QA automático após alterações ] perfil: auto ▍ APARÊNCIA ──────────────────────────────────────────────── [ ● Abrir painel ao iniciar ] [ ● Notificar ao concluir tare [ Salvar roteamento p/ este projeto ] [ Restaurar padrão ] [
Pane · bigbear-showcase
▄▀▀▀▀▀ ▀▀▀▀▀▀▀▀▄▄▄▄▄▄▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ 🐻 BIG BEAR DEV ORCHESTRATOR Code Smarter. Build Faster. Spend Less. ● Ocioso · BALANCED MODE ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ■ HAIKU — ■ SONNET — ■ OPUS — ╭───────────────────────╮ ╭───────────────────────╮ ╭─────── │ 100% │ │ 1/1 │ │ │ progresso │ │ concluídas │ │ ╰───────────────────────╯ ╰───────────────────────╯ ╰─────── Economia é estimativa em custo equivalente de API, não economia garantida de limite.
README

<img src="assets/readme/banner.png" alt="BIG BEAR DEV ORCHESTRATOR: Code Smarter. Build Faster. Spend Less." width="100%">

License: MIT Claude Code mod Tests TypeScript Telemetry: none

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


What is it?

BIG BEAR DEV ORCHESTRATOR is a mod for Claude Code. It sits beside your session and does five jobs:

  1. Routes each request to Haiku, Sonnet or Opus, using free local rules (no extra model calls).
  2. Tracks every task and subagent in a live side pane, with real states, timers and ETAs.
  3. Measures tokens, API-equivalent cost and estimated savings, per model.
  4. Watches your 5-hour and weekly usage windows, straight from Claude Code's own numbers.
  5. Guards your repo: dangerous commands, secrets, critical files, git state, and real test runs.

It is one plugin with independent modules. Turn any of them off; the rest keep working.

IdleResearchingWorking
<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.

The panel

╭──────────────────────────────────────────────────────────╮
│ [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.

Features

ModuleWhat it really does
Smart BrainClassifies 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 modesECO (cheapest that works), BALANCED (default), GOD (most capable where it matters; it is not "Opus for everything").
Task CommandLive 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 IntelligenceInput, 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 Monitor5-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 GuardianAsks 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 GuardianBranch, ahead/behind, changes, conflicts, merge state, last commit. Warns on pushes to protected branches and commits with failing QA.
Auto QAFinds 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 ManagerWarns as the context window fills. Never compacts on its own. Keeps technical decisions you record.
Dev AnalyticsLocal history: success rate by task type, average time, model mix, cost. Feeds the ETA and the routing.
ShowcaseA 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.

Install

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/repo shorthand 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.

Commands

CommandEffect
/bigbearOpen or close the pane
/bigbear statusOverall 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 · onTurn routing off or on. Off means stock Claude Code behavior
/bigbear tasks · usage · models · history · summaryInspect
`/bigbear qa [profile\list]`Run your project's real checks
/bigbear bug <error>Bug Hunter
/bigbear decision <text>Record a technical decision
/bigbear settingsSettings 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 95Usage alert thresholds
/bigbear price <tier> <in> <out> <read> <write>Update prices (USD per million tokens)
/bigbear reset · project resetRestore 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).

How routing works

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
ECOsearch, read, docs, small editsfeatures, bugs, testsonly critical and complex
BALANCEDsearch, read, simple editsfeatures, bugs, refactors, testsarchitecture, audits, critical
GODtrivial chatsimple buildsanything complex or risky
  • The model id for each tier is configurable. The defaults are 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.
  • Switching models rebuilds the prompt cache. BIG BEAR avoids a switch while the cache is warm and the conversation is large, and limits mid-turn escalation to once per turn.
  • An optional AI classifier exists for low-confidence prompts. It is off by default because it costs a call.

Honest numbers

BIG BEAR does not make up metrics.

  • API equivalent is what the same tokens would cost at API list prices. It is not what your subscription bills.
  • Smart Savings compares that cost with a reference model (Sonnet by default, configurable) for the same tokens. It is an estimate. Routing makes a request cheaper, not smaller, so "tokens saved" means the saved share of the reference bill expressed in tokens. It is not a refund and not a guarantee about your weekly limit.
  • 5H and WEEK come only from Claude Code's reported rate-limit windows. When there is no reading you see N/D — indisponível.
  • ETA comes only from finished tasks in your history. Until there is enough of it, the pane says "calculando".
  • A task is COMPLETED only when the engine reports the turn ended with an answer (and, if Auto QA is on, QA passed).
  • Default prices come from the Anthropic model table bundled with Claude Code (checked 2026-10-06). Cache write and Haiku cache read prices are derived with the standard multipliers. Edit them any time with /bigbear price.

The interface text is currently in Portuguese (pt-BR), the author's language. The commands, settings and docs are in English.

Privacy and safety

  • Nothing leaves your machine. No telemetry, no external servers, no network calls of its own.
  • History keeps metrics only (category, model, duration, tokens, cost). Never code, prompts or credentials. Task titles and QA output pass through a secret filter.
  • The Code Guardian never approves anything for you: "Allow" only lets a command continue to Claude Code's normal permission check. It never bypasses approvals or other plugins' hooks.
  • Turn routing off and Claude Code behaves exactly as before.
  • A failing module is skipped by the engine; the rest keep running.

Project profiles

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.

Architecture

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

Development

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.

Known limitations

  • Early-access API; needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
  • While routing is on, BIG BEAR's choice replaces /model for each request. Use /bigbear off or /bigbear model <tier> to take control back.
  • 5H and WEEK need a subscription and appear after the first response of a session.
  • WAITING means a tool is running; the mod cannot see whether a tool waits for your approval.
  • There is no official API to capture the interface as an image, so Showcase is for manual screenshots; export produces text.
  • The pane width and docking are decided by Claude Code.

Contributing

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.

License

MIT © 2026 jonathasmoraes01. The bear artwork in assets/ is included for use with this project.

🐻 Code Smarter. Build Faster. Spend Less.

Source 29 files
hooks/register.tsx 267 lines
1import { 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}
267
hooks/src/commands.ts 416 lines
1import 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
416
hooks/src/core/orchestrator.ts 350 lines
1import 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}
350
hooks/src/config/defaults.ts 134 lines
1import 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}
134
hooks/src/core/runtime.ts 39 lines
1import 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}
39
hooks/src/core/tasks.ts 122 lines
1import 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}
122
hooks/src/modules/guard-hooks.ts 81 lines
1import 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}
81
hooks/src/modules/monitor.ts 62 lines
1import 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}
62
hooks/src/services/engine.ts 199 lines
1import 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}
199
hooks/src/services/io.ts 28 lines
1import 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}
28
hooks/src/services/pricing.ts 152 lines
1import 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}
152
hooks/src/ui/band.tsx 87 lines
1
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