SLOPSHOPPER

glassbox

Watch Claude work, in one pane beside the transcript: the loop, context and what fills it, the plan, subagents, permission checks, file changes and activity

newpanebandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glassbox
│ ┃ glassbox ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./loop.tsx │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭ context 49% ────────────────────────────── ⎿ Read 6 lines │ ┃ │ █████████████████▏░░░░░░░░░░░░░░░░░ 49% 97 ⏺ Update(src/auth.ts) │ ┃ │ $0.42 5h █▉░░░░ 31% ⎿ Added 2 lines, removed 1 line │ ┃ ╰─────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ╭ activity ───────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ Nothing yet. │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /glassbox │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · glassbox
▣ client module ./loop.tsx — · idle ╭ context 49% ─────────────────────────────────────────╮ │ █████████████████▏░░░░░░░░░░░░░░░░░ 49% 97k/200k │ │ $0.42 5h █▉░░░░ 31% │ ╰──────────────────────────────────────────────────────╯ ╭ activity ────────────────────────────────────────────╮ │ Nothing yet. │ ╰──────────────────────────────────────────────────────╯
README

Claude Configuration Repository: Smart Agent Orchestration Framework

Continuous Integration Status: Passing Pull Request Checks Status: Passing License: MIT License - Open source software license Version 2.1 - Current software release version 12 Specialized Agents - Consolidated agent ecosystem 20 Essential Commands - Comprehensive command toolkit 5 Lightweight Skills - Focused domain expertise 42 Documentation Files - Comprehensive documentation coverage

Production-Ready Smart Agent Orchestration Framework for Claude Code CLI

Quick Start Guide • Installation Instructions • Core Features • Agent Ecosystem Overview • Command Reference • Documentation Index • Contributing Guidelines

🎯 Overview

This repository provides a comprehensive Smart Agent Orchestration Framework for Claude Code CLI, featuring 12 specialized agents (consolidated from 31) organized across 6 functional domains, 20 essential commands, and 5 lightweight skills forming a three-tier execution model. With multi-instance parallelization delivering 4-6x performance improvements and intelligent task delegation, the framework transforms development workflows through smart orchestration, coordinated parallel execution, and continuous quality validation.

🌟 What Makes This Special

  • Three-Tier Execution: Direct execution, Skills (lightweight expertise), and Agents (complex specialists)
  • 12 Specialized Agents: Consolidated from 31 for focused coverage across Development, Quality, Architecture, Infrastructure, Research, and Documentation domains
  • 20 Essential Commands: Comprehensive toolset for development, testing, deployment, and quality assurance
  • 5 Lightweight Skills: Focused domain expertise (YAML, Markdown, Python, Bash, Git workflows)
  • Multi-Instance Parallelization: Deploy 3-8 instances of the same agent type for massive performance gains
  • One-Command Deployment: Complete framework setup with /sync command
  • Production-Ready Quality: Comprehensive testing, validation, and security boundaries
  • 42 Documentation Files: Extensive guides, tutorials, and reference materials

🎪 Live Demo: See It in Action

Experience the power of parallel agent orchestration:

# Traditional approach: 3-5 minutes
# Framework approach: 30-45 seconds (5-6x faster)
/audit --scope agents

# Traditional approach: 2-3 minutes
# Framework approach: 30-40 seconds (4-5x faster)
/test

# Traditional approach: 5-7 minutes
# Framework approach: 1-2 minutes (3-4x faster)
/docs

🚀 Quick Start

Prerequisites

Ensure you have Claude Code CLI installed:

# Install via npm (recommended)
npm install -g @anthropic/claude-code

# Or via Homebrew (macOS)
brew install claude-code

# Verify installation
claude-code --version

5-Minute Setup

# 1. Clone the repository
git clone https://github.com/damilola-elegbede/claude-config.git
cd claude-config

# 2. Launch Claude Code CLI
claude-code

# 3. Deploy complete framework with one command
/sync

# 4. Verify installation with ecosystem health check
/audit --scope all

# 5. Experience the power - try these commands
/prime      # Repository analysis with 5 parallel specialists
/test       # Intelligent test execution with auto-discovery
/review     # Multi-dimensional code review

🎉 You're ready! You now have access to 12 specialized agents and 20 essential commands.

First Commands to Try

# Multi-agent repository analysis with parallel execution
/prime

# Comprehensive test execution with framework auto-discovery
/test

# Multi-dimensional code review (code-reviewer + security-auditor + test-engineer)
/review

# Documentation generation with 6 parallel processors
/docs

# Smart dependency audit with security scanning
/deps

🛠️ Core Features

🤖 Claude Sonnet 4.6 Powered

  • Latest Model: Built for Claude Sonnet 4.6 (released February 2026)
  • Enhanced Reasoning: Enhanced capabilities for complex problem-solving
  • Extended Thinking: Native support for megathink/ultrathink reasoning modes
  • Faster Performance: Improved response times enable more aggressive parallelization
  • Agent Distribution: 11 Sonnet agents, 1 Opus agent (12 total specialized agents)

🧠 Smart Agent Orchestration

  • 12 Specialized Agents: Consolidated coverage across all development domains
  • Multi-Instance Parallelization: Deploy multiple instances of the same agent type
  • Intelligent Task Delegation: Automatic specialist selection based on task complexity
  • Coordinated Wave Execution: Dependency-aware parallel execution patterns
  • Continuous Improvement: Performance feedback loops with adaptive optimization

⚡ Performance Improvements

Table: Performance Improvements with Smart Agent Orchestration Framework

OperationBefore FrameworkAfter FrameworkPerformance ImprovementTechnology Implementation
Agent Ecosystem Audit3-5 min30-45 sec5-6x faster8 parallel agent-auditors
Test Suite Execution2-3 min30-40 sec4-5x faster5 test framework instances
Documentation Generation5-7 min1-2 min3-4x faster6 document processors
Repository Analysis1-2 min15-20 sec4-6x faster5 analyzer instances
Dependency Security Audit2 min20-30 sec4-6x fasterPer-ecosystem instances

🔄 Configuration Management

  • One-Command Deployment: Complete framework setup with /sync
  • Automatic Validation: YAML compliance and security boundary checking
  • Intelligent Backup: Automatic backup creation with rollback capabilities
  • MCP Server Integration: Seamless integration with Model Context Protocol servers
  • Cross-Platform Support: Works on macOS, Linux, and Windows

🛡️ Security & Quality

  • SYSTEM BOUNDARY Protection: Multi-layered prevention of unauthorized agent invocations
  • Zero-Tolerance Quality Gates: Comprehensive pre-commit and pre-push validation
  • Security-First Design: Principle of least privilege with role-based access control
  • Comprehensive Testing: Full test coverage across all components
  • Audit Logging: Complete tracking of all agent actions and decisions

💡 Skills: Lightweight Expertise

The framework includes 5 lightweight skills that provide focused domain expertise without orchestration overhead, filling the gap between direct execution and full agent delegation.

Three-Tier Execution Model

Level 1: Direct Execution        → Simple, deterministic tasks (< 5 min)
Level 2: Skills                 → Lightweight expertise (YAML, Markdown, Python)
Level 3: Agents                 → Complex specialists (backend-engineer, ml-engineer)

Core Skills (Tier 1)

SkillCategoryFocusUse When
yamlformatYAML syntax, frontmatter validationCreating/editing agents and commands
markdownformatMarkdown linting, documentationWriting/fixing documentation
pythonlanguagePython patterns, validation scriptsWriting automation tools
bashworkflowShell scripting, git hooksBuilding workflows and hooks
git-workflowsworkflowGit operations, branching, commitsManaging version control

Skills vs Agents

  • Skills: Quick reference, format-specific, no orchestration (seconds)
  • Agents: Multi-step tasks, strategic decisions, tool orchestration (minutes)

Example:

User: "Validate this YAML frontmatter"
→ Uses yaml skill (seconds)

User: "Implement a new backend API"
→ Uses backend-engineer agent (minutes, multi-step)

See Skills Guide for detailed documentation and usage patterns.

🎭 Agent Ecosystem: 12 Specialists

The framework features 12 specialized agents (consolidated from 31) organized across 6 functional domains for complete development lifecycle coverage with reduced complexity.

📊 Agent Categories Overview

CategoryCountKey SpecialistsPurpose
Development3backend-engineer, frontend-engineer, data-engineerCore programming and implementation
Architecture1architectSystem, API, cloud, and frontend architecture
Quality3code-reviewer, security-auditor, test-engineerTesting, validation, and quality assurance
Infrastructure2devops, debuggerOperations, CI/CD, and debugging
Research1researcherTech research and codebase analysis
Resume/Career1career-assistantJob applications and career support
Documentation1tech-writerTechnical documentation and READMEs

Consolidation Summary

19 agents were consolidated into the remaining 12:

  • architect absorbs: principal-architect, api-architect, cloud-architect, frontend-architect
  • career-assistant absorbs: jd-analyzer, resume-optimizer, content-writer, career-strategist
  • code-reviewer absorbs: accessibility-auditor
  • data-engineer absorbs: database-admin
  • debugger absorbs: performance-engineer
  • devops absorbs: platform-engineer
  • frontend-engineer absorbs: ui-designer
  • researcher absorbs: codebase-analyst, ux-researcher, business-analyst, product-strategist

🚀 Smart Orchestration Examples

Multi-Platform Development Scenario
Project: Full-Stack Application
Strategy: Parallel specialist deployment across platforms
Execution:
  Wave 1:
    - architect: System architecture and API design
    - researcher: Requirements analysis and user research
  Wave 2:
    - backend-engineer: API implementation and microservices
    - frontend-engineer: Web application development
  Wave 3:
    - test-engineer: Comprehensive testing strategy and automation
    - security-auditor: Security assessment and vulnerability testing
    - debugger: Performance optimization and monitoring

Result: 70% faster delivery through coordinated parallel execution
Quality Assurance Orchestration
Project: Production Readiness Assessment
Strategy: Multi-dimensional validation with parallel specialists
Execution:
  - code-reviewer: Code quality analysis and best practices validation
  - security-auditor: Security vulnerability assessment and compliance
  - test-engineer: Test coverage analysis and automation strategy
  - debugger: Performance bottleneck identification and optimization

Quality Gates: 95% coverage across all validation dimensions
Performance: 5x faster than sequential validation

🛠️ Essential Commands

⭐⭐⭐⭐⭐ Five-Star Commands (Core Orchestration)

CommandDescriptionPerformance GainKey Features
/syncDeploy complete framework configurationsN/AOne-command setup, validation, backup
/testMulti-agent test execution with auto-discovery4-5x faster5 test suite instances, framework detection
/primeParallel repository analysis and insights4-6x faster5 analyzer instances, comprehensive profiling
/auditUnified ecosystem validation (agents/commands/all)5-6x fasterParallel validation, comprehensive coverage
/reviewMulti-dimensional quality analysisEnhanced coverageParallel specialists, comprehensive assessment
/docsDocumentation orchestration with parallel processors3-4x faster6 document instances, automated generation
/planStrategic project planning with principal-architectEnhanced qualityTDD methodology, architectural guidance
/debugSystematic investigation with evidence gatheringImproved accuracyHypothesis testing, systematic debugging
/resolve-commentsIntelligent PR resolution based on comment analysisContext-awareMulti-agent deployment, automated fixes
/depsSecurity-first dependency managementVulnerability scanningMulti-language support, security assessment
/fix-ciAutomated CI/CD failure resolutionPattern recognitionDevOps expertise, automated remediation
/prIntelligent PR creation with tech-writer collaborationEnhanced descriptionsContext-aware analysis, professional documentation
/implementation-planGenerate detailed implementation plans without executionPlanning accelerationTask breakdown, risk assessment, verification planning
/verifyCommand execution verification with percentage alignment analysisQuality assuranceMulti-wave analysis, requirement validation, actionable recommendations

⭐⭐⭐⭐ Four-Star Commands (Enhanced Operations)

  • /commit - Enhanced Git operations with repository hygiene features, automatic temporary file cleanup, and professional repository standards maintenance
  • /push - Safe repository operations with comprehensive validation
  • /branch - Context-aware branching with intelligent naming conventions
  • /deploy - Production deployment with comprehensive orchestration
  • /monitor - System monitoring with intelligent alerting and analysis
  • /audit --scope commands - Command quality assurance and ecosystem validation

⭐⭐⭐ Three-Star Commands (Utility & Support)

  • /ship-it - Release management with comprehensive workflow automation
  • /prompt - Prompt development and testing utility for optimization

💾 Installation

Option 1: Quick Setup (Recommended)

Perfect for most users wanting immediate access to the complete framework:

# Clone the repository
git clone https://github.com/damilola-elegbede/claude-config.git
cd claude-config

# Launch Claude Code CLI
claude-code

# Deploy complete framework
/sync

# Verify installation
/audit --scope agents
/prime --lite

Option 2: Custom Installation

For users who want selective component installation:

# Clone repository
git clone https://github.com/damilola-elegbede/claude-config.git
cd claude-config

# Create Claude configuration directory
mkdir -p ~/.claude

# Install specific components:

# Core agents only
cp -r system-configs/.claude/agents ~/.claude/agents

# Essential commands only
cp -r system-configs/.claude/commands ~/.claude/commands

# Audio notifications (macOS)
cp system-configs/.claude/settings.json ~/.claude/settings.json

# Verify installation
claude-code
/audit --scope agents

Option 3: Development Installation

For contributors and advanced users:

# Fork and clone your fork
git clone https://github.com/YOUR-USERNAME/claude-config.git
cd claude-config

# Add upstream remote
git remote add upstream https://github.com/damilola-elegbede/claude-config.git

# Run comprehensive validation
./tests/test.sh
./scripts/validate-agent-yaml.py

# Deploy development configuration
/sync

# Run full ecosystem validation
/audit --scope agents
/audit --scope commands

System Requirements

  • Claude Code CLI: Version 1.0 or higher
  • Operating System: macOS, Linux, or Windows
  • Python: 3.8+ (for validation scripts)
  • Node.js: 16+ (for npm installation method)
  • Disk Space: ~50MB for complete framework
  • Memory: 4GB+ recommended for optimal performance

📋 The /sync Command: Framework Deployment

The /sync command is the cornerstone of the framework deployment system, providing one-command setup with comprehensive validation and rollback capabilities.

Basic Usage

# Standard deployment
/sync

# Preview changes without deployment
/sync --dry-run

# Force deployment with backup
/sync --backup --force

What Gets Deployed

The sync process deploys the complete framework configuration:

  • 12 Agent Definitions: All specialist agents to ~/.claude/agents/
  • 20 Command Definitions: Essential commands to ~/.claude/commands/
  • Output Styles: Formatting configurations to ~/.claude/output-styles/
  • Mods: Hook-module plugins (e.g. glassbox, a live view of Claude's work; jevlight, a marker each time Jev acts) to ~/.claude/mods/, loaded via CLAUDE_CODE_PLUGIN_DIRS
  • System Settings: Audio notifications and preferences to ~/.claude/settings.json
  • MCP Server Configuration: Model Context Protocol server integration
  • Statusline Integration: Intelligent terminal statusline for development context

Deployment Process

  1. Pre-deployment Validation: YAML syntax, permissions, and configuration integrity
  2. Automatic Backup Creation: Complete backup of existing configurations
  3. File Synchronization: Efficient deployment using rsync with exclusions
  4. MCP Server Integration: Seamless integration with Claude Desktop configuration
  5. Post-deployment Validation: Comprehensive verification of all components
  6. Rollback on Failure: Automatic restoration if any deployment step fails

Expected Output

🔄 Syncing Claude configurations...
📁 Source: system-configs/.claude/ (56 files)
📁 Target: ~/.claude/

✅ Pre-sync validation:
  - Configuration syntax: Valid (12 agents, 20 commands)
  - Target directory: Ready
  - Permissions: OK

💾 Creating backup: ~/.claude.backup.20250909_143022

🔄 Synchronizing files:
  ✅ Agents: 12 files → ~/.claude/agents/
  ✅ Skills: 34 skills → ~/.claude/skills/
  ✅ Output styles: 8 files → ~/.claude/output-styles/
  ✅ Settings: settings.json, statusline.sh, exit_hook.sh, session_start_version_check.sh

📡 MCP Server Configuration:
  ✅ Updated Claude Desktop config with MCP servers:
    - filesystem, github, shadcn-ui
    - context7, notionApi

✅ Post-sync validation:
  - File integrity: All files copied successfully
  - Agent configs: 12/12 valid
  - Commands: 20/20 functional
  - MCP integration: 5/5 connected

📊 Sync completed successfully:
  Files synced: 56 total
  Backup location: ~/.claude.backup.20250909_143022
  Sync time: 2.3 seconds

🧪 Testing & Validation

Comprehensive Test Suite

# Run all tests with intelligent orchestration
/test

# Run specific test categories
./tests/test.sh commands
./tests/test.sh config
./tests/test.sh integration
./tests/test.sh performance

# Validate agent YAML compliance
./scripts/validate-agent-yaml.py

# Command behavioral validation
/audit --scope commands

Test Coverage Areas

  • ✅ Command Validation: Behavioral testing and functionality verification
  • ✅ Agent Configuration: YAML schema compliance and security boundaries
  • ✅ Integration Testing: End-to-end workflow validation
  • ✅ Security Validation: SYSTEM BOUNDARY protection and access control
  • ✅ Performance Testing: Benchmark validation and regression detection
  • ✅ Documentation Testing: Consistency and accuracy verification

🏗️ Architecture & Project Structure

Repository Structure

claude-config/
├── README.md                       # This comprehensive documentation
├── QUICKSTART.md                   # 5-minute setup guide
├── CONTRIBUTING.md                 # Development and contribution guidelines
├── SECURITY.md                     # Security policies and reporting
├── LICENSE                         # MIT license
├── system-configs/                 # Source-of-truth configurations
│   ├── .claude/                   # Claude Code configuration
│   │   ├── agents/                # 12 agent definitions
│   │   │   ├── backend-engineer.md
│   │   │   ├── frontend-engineer.md
│   │   │   ├── security-auditor.md
│   │   │   ├── test-engineer.md
│   │   │   └── ... (8 more agents)
│   │   ├── commands/              # 20 command definitions
│   │   │   ├── sync.md
│   │   │   ├── test.md
│   │   │   ├── prime.md
│   │   │   ├── agent-audit.md
│   │   │   ├── verify.md
│   │   │   └── ... (15 more commands)
│   │   ├── output-styles/         # Formatting configurations
│   │   ├── mods/                  # Hook-module plugins (glassbox, jevlight)
│   │   ├── settings.json          # Hook configuration and audio preferences
│   │   ├── statusline.sh          # Terminal statusline integration
│   │   ├── exit_hook.sh           # SessionEnd cleanup hook
│   │   └── session_start_version_check.sh  # SessionStart upgrade/CHANGELOG capture hook
├── docs/                          # 42 comprehensive documentation files
│   ├── setup/                     # Installation and configuration guides
│   ├── development/               # Development guidelines and requirements
│   ├── performance/               # Performance optimization guides
│   ├── quality/                   # Quality assurance documentation
│   ├── architecture/              # System architecture documentation
│   ├── agents/                    # Agent templates and categories
│   ├── api/                       # API documentation and specifications
│   ├── guides/                    # Tutorials and comprehensive gu
Source 5 files
hooks/register.tsx 1022 lines
1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type {
5  AgentRun,
6  AgentState,
7  Change,
8  Check,
9  FeedItem,
10  FeedKind,
11  Loop,
12  Phase,
13  Task,
14} from "../types";
15import {
16  C,
17  addChange,
18  addTask,
19  applyTodos,
20  bar,
21  changeOf,
22  clip,
23  clockTime,
24  duration,
25  gist,
26  kTokens,
27  meter,
28  oneLine,
29  prettyModel,
30  responseItems,
31  settle,
32  shortFile,
33  str,
34  tally,
35  timer,
36  tone,
37  updateTask,
38  verdictOf,
39} from "./model";
40
41// glassbox: watch Claude work, in one pane beside the transcript. A box per
42// concern (loop, context, plan, agents, gate, changes, activity); a box with
43// nothing to show takes no room. It draws nothing in the transcript or in the
44// status line, and opens only on /glassbox. Where a surface cannot dock a pane
45// (a terminal not in fullscreen) the engine seats it above the prompt, and
46// glassbox draws a shorter version there until it is closed. Where no surface
47// draws a pane at all, /glassbox answers with the same boxes in text.
48//
49// It only watches. Every recording hook passes the event on unchanged, and none
50// records until a screen has drawn, a remote client has attached or /glassbox
51// was typed: a headless session (`claude -p`, the fleet) does none of these
52// (its surface roster is empty), so there the hooks are a bare `next(e)`.
53
54const PANE = "glassbox";
55const PANE_COLUMNS = 52;
56const FEED_MAX = 200;
57// Activity rows the pane lists, newest on top; the person scrolls through them.
58const ACTIVITY_MAX = 50;
59const CHECKS_MAX = 60;
60const PLAN_ROWS = 8;
61// Seated above the prompt the pane shares the screen with the transcript, so
62// it lists fewer rows and skips the context anatomy.
63const INLINE_ACTIVITY_ROWS = 8;
64const INLINE_PLAN_ROWS = 4;
65const PHASES = ["prompt", "think", "tool", "result"];
66const PLAN_TOOLS = new Set([
67  "TaskCreate",
68  "TaskUpdate",
69  "TaskGet",
70  "TaskList",
71  "TodoWrite",
72]);
73
74const IDLE_LOOP: Loop = {
75  model: "",
76  phase: "idle",
77  turnStartedAt: null,
78  turnEndedAt: null,
79  compactions: 0,
80};
81
82const tasks = atom({ plugin: "glassbox", key: "tasks" } as const, [] as Task[]);
83const agents = atom(
84  { plugin: "glassbox", key: "agents" } as const,
85  [] as AgentRun[],
86);
87const feed = atom(
88  { plugin: "glassbox", key: "feed" } as const,
89  [] as FeedItem[],
90);
91const checks = atom(
92  { plugin: "glassbox", key: "checks" } as const,
93  [] as Check[],
94);
95const changes = atom(
96  { plugin: "glassbox", key: "changes" } as const,
97  [] as Change[],
98);
99const loop = atom({ plugin: "glassbox", key: "loop" } as const, IDLE_LOOP);
100
101type Args = Record<string, unknown>;
102type Block = { type: string; text?: string; thinking?: string };
103type Usage = {
104  input_tokens?: number;
105  cache_read_input_tokens?: number;
106  cache_creation_input_tokens?: number;
107};
108
109// Module state (restarts on reload, which is fine: it only gates and filters).
110let hasScreen = false;
111// The agent whose activity the pane shows: an agent id, MAIN for the main
112// loop by choice, or null to follow the agent the transcript is viewing.
113const MAIN = "main";
114let focusId: string | null = null;
115// A subagent's counts and ending, kept apart from its row: a fast agent can
116// finish a step, a tool or its whole run before agent.spawn has added the
117// row, and the spawn merges what arrived early. Each writer records here
118// first and updates the row second, and the spawn reads here only after the
119// row exists, so every value lands on one or the other.
120type Early = {
121  ctx: number;
122  tools: number;
123  end?: { status: AgentState; endedAt: number };
124};
125const counts = new Map<string, Early>();
126const countsOf = (id: string): Early => counts.get(id) ?? { ctx: 0, tools: 0 };
127
128const engineState: Record<string, AgentState> = {
129  pending: "running",
130  running: "running",
131  waiting: "waiting",
132  idle: "waiting",
133  completed: "done",
134  failed: "failed",
135  killed: "killed",
136};
137
138const feedGlyph: Record<FeedKind, string> = {
139  tool: "▸",
140  thinking: "∴",
141  say: "›",
142  agent: "◆",
143  deny: "✗",
144};
145
146async function push($: EngineInterface, item: Omit<FeedItem, "at">) {
147  const at = await $.clock.now();
148  await update($, feed, (list) => [...list, { ...item, at }].slice(-FEED_MAX));
149}
150
151const setPhase = ($: EngineInterface, phase: Phase) =>
152  update($, loop, (l) => (l.phase === phase ? l : { ...l, phase }));
153
154async function reset($: EngineInterface) {
155  focusId = null;
156  counts.clear();
157  await update($, tasks, () => []);
158  await update($, agents, () => []);
159  await update($, feed, () => []);
160  await update($, checks, () => []);
161  await update($, changes, () => []);
162  await update($, loop, (l): Loop => ({ ...IDLE_LOOP, model: l.model }));
163}
164
165// Agent status as the engine knows it (failed, killed, waiting), over our own
166// record; an agent the engine has already dropped keeps what we last saw.
167async function liveAgents($: EngineInterface) {
168  const runs = await read($, agents);
169  const listed = await $.agent.list().catch(() => []);
170  const known = new Map(
171    listed.map((a) => [a.id, engineState[a.status] ?? "running"]),
172  );
173  return runs.map((r) => {
174    const status = known.get(r.id);
175    return status && r.status !== status ? { ...r, status } : r;
176  });
177}
178
179// The surfaces that show a mod's panes; elsewhere hooks run but nothing draws
180// (code.claude.com/docs/en/plugins/mods/overview, "Where mods run").
181const DRAWING_SURFACES = new Set(["terminal", "desktop"]);
182const SNAPSHOT_ACTIVITY = 10;
183// Each row of the snapshot is cut to this many characters, as the pane clips.
184const SNAPSHOT_TEXT = 120;
185
186// The pane's boxes as markdown, for a surface that draws no pane. A box with
187// nothing to show is left out, as in the pane.
188async function snapshot($: EngineInterface, wasRecording: boolean) {
189  const [l, list, runs, items, gate, edits, now, usage] = await Promise.all([
190    read($, loop),
191    read($, tasks),
192    liveAgents($),
193    read($, feed),
194    read($, checks),
195    read($, changes),
196    $.clock.now(),
197    $.session.usage({ breakdown: "summary" }).catch(() => null),
198  ]);
199  const model = prettyModel(l.model || usage?.context.breakdown?.model || "");
200  const timing =
201    l.phase !== "idle" && l.turnStartedAt !== null
202      ? `${l.phase} · ${timer(now - l.turnStartedAt)}`
203      : l.turnStartedAt !== null && l.turnEndedAt !== null
204        ? `idle · last turn ${duration(l.turnEndedAt - l.turnStartedAt)}`
205        : "idle";
206  const out = [`**glassbox** · ${[model, timing].filter(Boolean).join(" · ")}`];
207
208  const ctx = usage?.context;
209  if (ctx?.percent !== undefined && ctx.percent !== null) {
210    const size =
211      ctx.tokens !== undefined
212        ? ` (${kTokens(ctx.tokens)}/${kTokens(ctx.window)})`
213        : "";
214    const fold = l.compactions > 0 ? ` · compacted ${l.compactions}×` : "";
215    out.push(`**context** ${Math.round(ctx.percent)}%${size}${fold}`);
216  }
217  if (list.length > 0) {
218    const done = list.filter((x) => x.status === "completed").length;
219    out.push(`**plan** ${done}/${list.length}`);
220    for (const x of list.slice(0, PLAN_ROWS)) {
221      const text = clip(
222        x.status === "in_progress" ? (x.activeForm ?? x.subject) : x.subject,
223        SNAPSHOT_TEXT,
224      );
225      out.push(
226        x.status === "completed"
227          ? `- [x] ${text}`
228          : x.status === "in_progress"
229            ? `- [ ] **${text}**`
230            : `- [ ] ${text}`,
231      );
232    }
233    if (list.length > PLAN_ROWS) out.push(`- +${list.length - PLAN_ROWS} more`);
234  }
235  if (runs.length > 0) {
236    out.push(`**agents** ${runs.length}`);
237    for (const r of runs.slice(-6)) {
238      const took = timer((r.endedAt ?? now) - r.startedAt);
239      out.push(
240        `- ${r.status} · ${clip(r.description, SNAPSHOT_TEXT)} · ${r.tools} tools · ${took}`,
241      );
242    }
243  }
244  if (gate.length > 0) {
245    const n = tally(gate);
246    out.push(
247      `**gate** ${n.allowed} allowed · ${n.asked} asked · ${n.pending} waiting · ${n.denied} denied`,
248    );
249  }
250  if (edits.length > 0) {
251    out.push(`**changes** ${edits.length} files`);
252    for (const c of edits.slice(0, 6)) {
253      const about = c.approx ? "~" : "";
254      out.push(`- ${shortFile(c.file)} ${about}+${c.added} −${c.removed}`);
255    }
256  }
257  const shown = items
258    .filter((i) => !i.agentId)
259    .slice(-SNAPSHOT_ACTIVITY)
260    .reverse();
261  if (shown.length > 0) {
262    out.push("**activity**");
263    for (const i of shown)
264      out.push(
265        `- ${clockTime(i.at)} ${feedGlyph[i.kind]} ${clip(oneLine(i.text), SNAPSHOT_TEXT)}`,
266      );
267  } else if (!wasRecording) {
268    out.push(
269      "Recording from now: run /glassbox again to see plan, agents and activity.",
270    );
271  }
272  return out.join("\n");
273}
274
275// A watcher must never stand in the way: a hook that fails hands the event on
276// as if it were not there (replay-safe when it had already called `next`).
277const passThrough = <E, R>(_$: unknown, e: E, next: (e: E) => R) => next(e);
278
279export const register: Register = (on) => {
280  on("session.start", async ($, e, next) => {
281    await $.command.register({
282      name: "glassbox",
283      description:
284        "Open the glassbox pane: loop, context, plan, agents, permissions, changes, activity",
285    });
286    return next(e);
287  });
288
289  on("session.end", async ($, e, next) => {
290    if (e.reason === "clear") await reset($);
291    return next(e);
292  });
293
294  // Opens the pane and prints nothing where a surface draws one (the terminal,
295  // the desktop app). Where nothing draws (the VS Code chat panel, a cloud
296  // session, Remote Control from claude.ai or the phone) it answers with a
297  // snapshot in text instead, as does
298  // `/glassbox text` anywhere, and records from then on for the next one.
299  on("command.run", { command: "glassbox" }, async ($, e) => {
300    const surfaces = await $.session.surfaces();
301    const draws = surfaces.some((s) => DRAWING_SURFACES.has(s));
302    // The origin names how a command came, not which client typed it, and a
303    // client that draws nothing (VS Code) need not join the roster. Typed at
304    // the terminal, the person sees its pane. Any other origin opens the pane
305    // only with the desktop app attached; Remote Control (claude.ai, the
306    // phone) never, since its pane would open on the machine, out of sight.
307    const kind = e.origin?.kind ?? "composer";
308    const seen =
309      kind === "composer" ||
310      (kind !== "bridge" && surfaces.includes("desktop"));
311    if (!draws || !seen || (e.args ?? "").trim() === "text") {
312      const text = await snapshot($, hasScreen);
313      hasScreen = true;
314      return { text };
315    }
316    const opened = await $.ui.open({
317      id: PANE,
318      title: "glassbox",
319      columns: PANE_COLUMNS,
320    });
321    if (!opened.isPlaced) $.ui.toast(`glassbox: ${opened.reason}`);
322    return {};
323  });
324
325  on("prompt.submit", async ($, e, next) => {
326    if (!hasScreen) return next(e);
327    const now = await $.clock.now();
328    await update(
329      $,
330      loop,
331      (l): Loop => ({
332        ...l,
333        phase: "prompt",
334        turnStartedAt: now,
335        turnEndedAt: null,
336      }),
337    );
338    return next(e);
339  }).catch(passThrough);
340
341  // One model request: the main loop is thinking; a subagent's context grows.
342  on("turn.step", async function* ($, e, next) {
343    if (!hasScreen) return yield* next(e);
344    if (!e.agentId) {
345      await update(
346        $,
347        loop,
348        (l): Loop => ({ ...l, model: e.model, phase: "think" }),
349      );
350      return yield* next(e);
351    }
352    const result = yield* next(e);
353    const u = (result.usage ?? {}) as Usage;
354    const ctx =
355      (u.input_tokens ?? 0) +
356      (u.cache_read_input_tokens ?? 0) +
357      (u.cache_creation_input_tokens ?? 0);
358    const id = e.agentId;
359    if (ctx > 0) {
360      counts.set(id, { ...countsOf(id), ctx });
361      await update($, agents, (list) =>
362        list.map((r) => (r.id === id ? { ...r, ctx } : r)),
363      );
364    }
365    return result;
366  });
367
368  on("turn.complete", async ($, e, next) => {
369    if (!hasScreen) return next(e);
370    const now = await $.clock.now();
371    if (e.agentId) {
372      const status: AgentState =
373        e.reason === "answer"
374          ? "done"
375          : e.reason === "aborted"
376            ? "killed"
377            : "failed";
378      const id = e.agentId;
379      counts.set(id, { ...countsOf(id), end: { status, endedAt: now } });
380      await update($, agents, (list) =>
381        list.map((a) =>
382          a.id === e.agentId && a.status === "running"
383            ? { ...a, status, endedAt: now }
384            : a,
385        ),
386      );
387    } else {
388      await update(
389        $,
390        loop,
391        (l): Loop => ({ ...l, phase: "idle", turnEndedAt: now }),
392      );
393    }
394    return next(e);
395  });
396
397  on("session.compact", async ($, e, next) => {
398    const done = await next(e);
399    if (hasScreen && !e.agentId && e.trigger !== "precompute")
400      await update(
401        $,
402        loop,
403        (l): Loop => ({ ...l, compactions: l.compactions + 1 }),
404      );
405    return done;
406  });
407
408  on("agent.spawn", async ($, e, next) => {
409    const started = await next(e);
410    if (!hasScreen || !("agentId" in started) || !started.agentId)
411      return started;
412    const run: AgentRun = {
413      id: started.agentId,
414      description: e.description,
415      type: e.subagentType,
416      status: "running",
417      startedAt: await $.clock.now(),
418      tools: 0,
419      ctx: 0,
420    };
421    await update($, agents, (list) => [...list, run]);
422    // Read after the row exists, so a count arriving now lands on one or other.
423    const early = countsOf(run.id);
424    counts.delete(run.id);
425    if (early.ctx > 0 || early.tools > 0 || early.end)
426      await update($, agents, (list) =>
427        list.map((r) =>
428          r.id === run.id
429            ? {
430                ...r,
431                ctx: r.ctx || early.ctx,
432                tools: Math.max(r.tools, early.tools),
433                ...(early.end && r.status === "running" ? early.end : {}),
434              }
435            : r,
436        ),
437      );
438    await push($, {
439      agentId: e.parentAgentId,
440      kind: "agent",
441      text: `${e.subagentType}: ${e.description}`,
442    });
443    return started;
444  }).catch(passThrough);
445
446  // Every permission verdict; an ask is settled by the tool.call around it.
447  on("tool.check", async ($, e, next) => {
448    const verdict = await next(e);
449    if (!hasScreen || !e.tool_use_id) return verdict;
450    const check: Check = {
451      id: e.tool_use_id,
452      tool: e.tool,
453      verdict: verdictOf(verdict.decision),
454    };
455    await update($, checks, (list) => [...list, check].slice(-CHECKS_MAX));
456    return verdict;
457  }).catch(passThrough);
458
459  on("tool.call", async ($, e, next) => {
460    if (!hasScreen) return next(e);
461    const a = e as unknown as Args;
462    const { tool, agentId } = e;
463
464    // Shown as it starts, without holding the call up. The plan box shows the
465    // main loop's checklist, so the feed leaves those calls out; a subagent's
466    // stay, since the plan never shows them.
467    const inPlan = PLAN_TOOLS.has(tool) && !agentId;
468    if (!inPlan) void push($, { agentId, kind: "tool", text: gist(tool, a) });
469    if (!agentId) await setPhase($, "tool");
470    const ran = await next(e);
471    const didRun = !("deny" in ran && ran.deny);
472    await update($, checks, (list) => settle(list, e.tool_use_id, didRun));
473
474    if (agentId) {
475      counts.set(agentId, {
476        ...countsOf(agentId),
477        tools: countsOf(agentId).tools + 1,
478      });
479      await update($, agents, (list) =>
480        list.map((r) => (r.id === agentId ? { ...r, tools: r.tools + 1 } : r)),
481      );
482    } else {
483      await setPhase($, "result");
484    }
485    if (!didRun) {
486      await push($, {
487        agentId,
488        kind: "deny",
489        text: `denied · ${gist(tool, a)}`,
490      });
491      return ran;
492    }
493    if (ran.isError) {
494      // A failed checklist call never reaches the plan, so the feed shows it.
495      if (inPlan) await push($, { kind: "tool", text: gist(tool, a) });
496      return ran;
497    }
498
499    const out = ran.result;
500    const change = changeOf(
501      tool,
502      a,
503      out && typeof out === "object" ? (out as Args) : {},
504    );
505    if (change) await update($, changes, (list) => addChange(list, change));
506
507    // The checklist is the main loop's plan.
508    if (agentId) return ran;
509    if (tool === "TodoWrite") {
510      await update($, tasks, (list) => applyTodos(list, a.todos));
511    } else if (tool === "TaskCreate") {
512      const made = (
513        ran.result as { task?: { id: string; subject: string } } | undefined
514      )?.task;
515      if (made)
516        await update($, tasks, (list) => addTask(list, made, a.activeForm));
517    } else if (tool === "TaskUpdate") {
518      await update($, tasks, (list) => updateTask(list, a));
519    }
520    return ran;
521  }).catch(passThrough);
522
523  // Narration and reasoning: each response row, main loop and subagents alike.
524  on("session.append", { door: "response" }, async ($, e, next) => {
525    if (!hasScreen) return next(e);
526    for (const item of responseItems(e.message.content as unknown as Block[])) {
527      await push($, { agentId: e.agentId, ...item });
528    }
529    return next(e);
530  }).catch(passThrough);
531
532  // The space above the prompt is only how glassbox learns a screen exists, so
533  // it starts recording before its pane opens. It draws nothing there.
534  on("ui.render", { component: "AbovePrompt" }, async (_$, e, next) => {
535    hasScreen = true;
536    return next(e);
537  });
538
539  // A remote client (desktop, the phone, VS Code) joining is a screen too: it
540  // may never draw the space above the prompt before /glassbox is typed.
541  on("session.attach", async (_$, e, next) => {
542    hasScreen = true;
543    return next(e);
544  });
545
546  on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
547    hasScreen = true;
548    const els = $.ui.resolve(e);
549    const { Box, Text, Button } = els;
550
551    // Seated above the prompt rather than docked beside the transcript.
552    const compact = e.props.placement === "inline";
553    const planMax = compact ? INLINE_PLAN_ROWS : PLAN_ROWS;
554
555    const hasClient = "Client" in els;
556    const W = Math.max(30, e.props.bodyColumns);
557    const inner = W - 4;
558    const [l, list, runs, items, gate, edits, now, usage] = await Promise.all([
559      read($, loop),
560      read($, tasks),
561      liveAgents($),
562      read($, feed),
563      read($, checks),
564      read($, changes),
565      $.clock.now(),
566      $.session.usage({ breakdown: "summary", columns: W }).catch(() => null),
567    ]);
568    const isWorking = l.phase !== "idle";
569
570    // A rounded frame with its title set into the top edge.
571    const frame = (
572      key: string,
573      title: string,
574      extra: string,
575      extraColor: string,
576      rows: unknown[],
577    ) => {
578      const used = 3 + title.length + (extra ? extra.length + 1 : 0);
579      return (
580        <Box key={key} flexDirection="column" width={W}>
581          <Text color={C.text}>
582            <Text color={C.frame}>╭ </Text>
583            <Text color={C.text} bold>
584              {title}
585            </Text>
586            {extra ? <Text color={extraColor}>{` ${extra}`}</Text> : null}
587            <Text
588              color={C.frame}
589            >{` ${"─".repeat(Math.max(1, W - used - 1))}╮`}</Text>
590          </Text>
591          {rows.map((row, i) => (
592            <Box key={`${key}-${i}`}>
593              <Text color={C.frame}>│ </Text>
594              <Box width={inner}>{row as never}</Box>
595              <Text color={C.frame}> │</Text>
596            </Box>
597          ))}
598          <Text color={C.frame}>{`╰${"─".repeat(W - 2)}╯`}</Text>
599        </Box>
600      );
601    };
602
603    const ticker = (
604      key: string,
605      since: number,
606      endAt: number | null,
607      color: string,
608      spin: boolean,
609    ) =>
610      hasClient ? (
611        <els.Client
612          key={key}
613          module="./ticker.tsx"
614          props={{ since, now, endAt, color, spin }}
615        />
616      ) : (
617        <Text color={color}>{timer((endAt ?? now) - since)}</Text>
618      );
619
620    // ---- one top line: the loop on the left, the model and the turn's clock
621    // on the right (the pane's tab already names glassbox), then a blank row.
622    const model = prettyModel(l.model || usage?.context.breakdown?.model || "");
623    const timing =
624      isWorking && l.turnStartedAt !== null
625        ? null
626        : l.turnStartedAt !== null && l.turnEndedAt !== null
627          ? `last ${duration(l.turnEndedAt - l.turnStartedAt)}`
628          : "idle";
629    const loopW = Math.max(
630      12,
631      W - `${model} · ${timing ?? "00:00"}`.length - 1,
632    );
633    const header = (
634      <Box
635        key="header"
636        justifyContent="space-between"
637        width={W}
638        marginBottom={1}
639      >
640        {hasClient ? (
641          <els.Client
642            key="loop"
643            module="./loop.tsx"
644            width={loopW}
645            height={1}
646            props={{ phase: l.phase, phases: PHASES }}
647          />
648        ) : (
649          <Text color={C.dim}>
650            {PHASES.map((p) => (p === l.phase ? p.toUpperCase() : p)).join(
651              " › ",
652            )}
653          </Text>
654        )}
655        <Box>
656          <Text color={C.dim}>{`${model} · `}</Text>
657          {timing === null && l.turnStartedAt !== null ? (
658            ticker("turn", l.turnStartedAt, null, C.live, false)
659          ) : (
660            <Text color={C.dim}>{timing}</Text>
661          )}
662        </Box>
663      </Box>
664    );
665
666    // ---- context: the window, what fills it (as /context counts), cost, limits
667    const ctx = usage?.context;
668    const pct = ctx?.percent ?? null;
669    const bd = ctx?.breakdown;
670    const contextRows: unknown[] = [];
671    if (pct !== null) {
672      const tail = ` ${Math.round(pct)}%`;
673      const size =
674        ctx?.tokens !== undefined
675          ? ` ${kTokens(ctx.tokens)}/${kTokens(ctx.window)}`
676          : "";
677      const m = meter(pct, inner - tail.length - size.length - 4);
678      contextRows.push(
679        <Text color={C.text} wrap="truncate">
680          <Text color={tone(pct)}>{m.on}</Text>
681          <Text color={C.frame}>{m.off}</Text>
682          <Text color={C.text} bold>
683            {tail}
684          </Text>
685          <Text color={C.dim}>{size}</Text>
686          {l.compactions > 0 ? (
687            <Text color={C.warn}>{` ⟲${l.compactions}`}</Text>
688          ) : null}
689        </Text>,
690      );
691    } else {
692      contextRows.push(<Text color={C.dim}>No reading yet.</Text>);
693    }
694    if (!compact && bd && bd.rawMaxTokens > 0) {
695      // Anatomy: one strip, each category in the colour /context gives it.
696      const used = bd.categories.filter(
697        (c) => c.kind === "used" && c.tokens > 0,
698      );
699      const cells = used.map((c) => ({
700        c,
701        n: Math.max(1, Math.round((c.tokens / bd.rawMaxTokens) * inner)),
702      }));
703      const filled = cells.reduce((s, x) => s + x.n, 0);
704      contextRows.push(
705        <Text color={C.text} wrap="truncate">
706          {cells.map((x) => (
707            <Text color={x.c.color}>{"▆".repeat(x.n)}</Text>
708          ))}
709          <Text color={C.frame}>{"▁".repeat(Math.max(0, inner - filled))}</Text>
710        </Text>,
711      );
712      contextRows.push(
713        <Text color={C.text} wrap="truncate">
714          {used
715            .slice()
716            .sort((a, b) => b.tokens - a.tokens)
717            .map((c) => (
718              <Text color={C.text}>
719                <Text color={c.color}>■</Text>
720                <Text
721                  color={C.dim}
722                >{` ${c.name.toLowerCase()} ${kTokens(c.tokens)}  `}</Text>
723              </Text>
724            ))}
725        </Text>,
726      );
727    }
728    if (usage && (usage.cost || usage.rateLimits.length > 0)) {
729      contextRows.push(
730        <Text color={C.text} wrap="truncate">
731          {usage.cost ? (
732            <Text
733              color={C.text}
734              bold
735            >{`$${usage.cost.usd.toFixed(2)}   `}</Text>
736          ) : null}
737          {usage.rateLimits.slice(0, 2).map((r) => {
738            const g = meter(r.percentUsed, 6);
739            const label =
740              r.kind === "five_hour"
741                ? "5h"
742                : r.kind === "seven_day"
743                  ? "7d"
744                  : r.kind;
745            return (
746              <Text color={C.text}>
747                <Text color={C.dim}>{`${label} `}</Text>
748                <Text color={tone(r.percentUsed)}>{g.on}</Text>
749                <Text color={C.frame}>{g.off}</Text>
750                <Text color={C.dim}>{` ${Math.round(r.percentUsed)}%   `}</Text>
751              </Text>
752            );
753          })}
754        </Text>,
755      );
756    }
757
758    // ---- plan
759    const done = list.filter((x) => x.status === "completed").length;
760    const planRows: unknown[] = [];
761    if (list.length > 0) {
762      const allDone = done === list.length;
763      planRows.push(
764        <Text color={allDone ? C.ok : C.live}>
765          {bar(done, list.length, inner)}
766        </Text>,
767      );
768      for (const x of list.slice(0, planMax)) {
769        const text =
770          x.status === "in_progress" ? (x.activeForm ?? x.subject) : x.subject;
771        planRows.push(
772          x.status === "completed" ? (
773            <Text color={C.text} wrap="truncate">
774              <Text color={C.ok}>✓ </Text>
775              <Text color={C.dim}>{clip(text, inner - 2)}</Text>
776            </Text>
777          ) : x.status === "in_progress" ? (
778            <Text wrap="truncate" color={C.live} bold>
779              {`◐ ${clip(text, inner - 2)}`}
780            </Text>
781          ) : (
782            <Text
783              color={C.text}
784              wrap="truncate"
785            >{`○ ${clip(text, inner - 2)}`}</Text>
786          ),
787        );
788      }
789      if (list.length > planMax)
790        planRows.push(
791          <Text color={C.dim}>{`+${list.length - planMax} more`}</Text>,
792        );
793    }
794
795    // ---- agents
796    const count = (...s: AgentState[]) =>
797      runs.filter((r) => s.includes(r.status)).length;
798    const running = count("running");
799    const waiting = count("waiting");
800    const failed = count("failed", "killed");
801    // The title names what is true now: running, else every state that ended.
802    const agentsTitle =
803      running > 0
804        ? `${running} running`
805        : [
806            waiting > 0 ? `${waiting} waiting` : "",
807            count("done") > 0 ? `${count("done")} done` : "",
808            failed > 0 ? `${failed} failed` : "",
809          ]
810            .filter(Boolean)
811            .join(" · ");
812    const agentsColor =
813      running > 0 ? C.live : waiting > 0 ? C.warn : failed > 0 ? C.bad : C.dim;
814    const agentRows = runs.slice(-6).map((r) => {
815      const isLive = r.status === "running";
816      const glyph = isLive
817        ? "◐"
818        : r.status === "done"
819          ? "✓"
820          : r.status === "waiting"
821            ? "◌"
822            : "✗";
823      const color = isLive
824        ? C.live
825        : r.status === "done"
826          ? C.ok
827          : r.status === "waiting"
828            ? C.warn
829            : C.bad;
830      const calls = `${r.tools} ${r.tools === 1 ? "tool" : "tools"} ·`;
831      const meta = r.ctx > 0 ? `${kTokens(r.ctx)} ctx · ${calls}` : calls;
832      return (
833        <Box width={inner} justifyContent="space-between">
834          <Box>
835            <Text color={color} bold={isLive}>{`${glyph} `}</Text>
836            <Button
837              key={`agent-${r.id}`}
838              plain
839              label={clip(r.description, Math.max(8, inner - 22))}
840              dimColor={!isLive && focusId !== r.id}
841              onPress={() => {
842                focusId = focusId === r.id ? MAIN : r.id;
843                return update($, feed, (f) => [...f]);
844              }}
845            />
846          </Box>
847          <Box>
848            <Text color={C.dim}>{`${meta} `}</Text>
849            {ticker(
850              `agent-clock-${r.id}`,
851              r.startedAt,
852              r.endedAt ?? (isLive ? null : now),
853              isLive ? C.live : C.dim,
854              isLive,
855            )}
856          </Box>
857        </Box>
858      );
859    });
860
861    // ---- gate: one cell per permission check
862    const n = tally(gate);
863    const cellColor = (c: Check) =>
864      c.verdict === "allowed"
865        ? C.ok
866        : c.verdict === "asked"
867          ? C.asked
868          : c.verdict === "pending"
869            ? C.warn
870            : C.bad;
871    const gateRows: unknown[] = gate.length
872      ? [
873          <Text color={C.text} wrap="truncate">
874            {gate.slice(-inner).map((c) => (
875              <Text color={cellColor(c)}>
876                {c.verdict === "denied" ? "✗" : "■"}
877              </Text>
878            ))}
879          </Text>,
880          <Text color={C.text} wrap="truncate">
881            <Text color={C.ok}>■</Text>
882            <Text color={C.dim}>{` ${n.allowed} allowed  `}</Text>
883            <Text color={C.asked}>■</Text>
884            <Text color={C.dim}>{` ${n.asked} asked  `}</Text>
885            {n.pending > 0 ? (
886              <Text color={C.warn}>{`■ ${n.pending} waiting  `}</Text>
887            ) : null}
888            <Text
889              color={n.denied > 0 ? C.bad : C.dim}
890            >{`✗ ${n.denied} denied`}</Text>
891          </Text>,
892        ]
893      : [];
894
895    // ---- changes: lines added and removed per file, scaled to the largest
896    const most = Math.max(1, ...edits.map((c) => c.added + c.removed));
897    const scale = 10;
898    const changeRows = edits.slice(0, 6).map((c) => {
899      // Round the whole bar once, then split it, so the two never overflow.
900      const cells = Math.round(((c.added + c.removed) / most) * scale);
901      const plus = Math.min(cells, Math.round((c.added / most) * scale));
902      const minus = cells - plus;
903      // "~" marks an estimate: counted from the call, without the tool's diff.
904      const about = c.approx ? "~" : "";
905      const nums = `${about}+${c.added} −${c.removed}`;
906      return (
907        <Box width={inner} justifyContent="space-between">
908          <Text color={C.text} wrap="truncate">
909            {clip(shortFile(c.file), inner - scale - nums.length - 3)}
910          </Text>
911          <Text color={C.text}>
912            <Text color={C.ok}>{"▮".repeat(plus)}</Text>
913            <Text color={C.bad}>{"▮".repeat(minus)}</Text>
914            <Text color={C.frame}>
915              {"·".repeat(Math.max(0, scale - plus - minus))}
916            </Text>
917            <Text color={C.dim}>{` ${about}`}</Text>
918            <Text color={C.ok}>{`+${c.added}`}</Text>
919            <Text color={C.bad}>{` −${c.removed}`}</Text>
920          </Text>
921        </Box>
922      );
923    });
924    if (edits.length > 6)
925      changeRows.push(
926        <Text color={C.dim}>{`+${edits.length - 6} more files`}</Text>,
927      );
928    const totalAdded = edits.reduce((s, c) => s + c.added, 0);
929    const totalRemoved = edits.reduce((s, c) => s + c.removed, 0);
930
931    // ---- activity: newest first, the last ACTIVITY_MAX; one agent when picked
932    const viewed =
933      focusId === MAIN ? null : (focusId ?? e.props.view?.agentId ?? null);
934    const focus = viewed ? runs.find((r) => r.id === viewed) : undefined;
935    const shown = items
936      .filter((i) => (viewed ? i.agentId === viewed : !i.agentId))
937      .slice(-(compact ? INLINE_ACTIVITY_ROWS : ACTIVITY_MAX))
938      .reverse();
939    const feedColor = (k: FeedKind) =>
940      k === "deny"
941        ? C.bad
942        : k === "agent"
943          ? C.live
944          : k === "thinking"
945            ? C.dim
946            : C.text;
947    const activityRows: unknown[] = shown.length
948      ? shown.map((i) => (
949          <Text color={C.text} wrap="truncate">
950            <Text color={C.dim}>{`${clockTime(i.at)} `}</Text>
951            <Text color={feedColor(i.kind)}>{`${feedGlyph[i.kind]} `}</Text>
952            <Text color={feedColor(i.kind)} italic={i.kind === "thinking"}>
953              {clip(i.text, inner - 8)}
954            </Text>
955          </Text>
956        ))
957      : [<Text color={C.dim}>Nothing yet.</Text>];
958    if (focus)
959      activityRows.unshift(
960        <Button
961          key="back"
962          plain
963          label="← main"
964          onPress={() => {
965            focusId = MAIN;
966            return update($, feed, (f) => [...f]);
967          }}
968        />,
969      );
970
971    return (
972      <Box flexDirection="column" width={W}>
973        {header}
974        {frame(
975          "context",
976          "context",
977          pct !== null ? `${Math.round(pct)}%` : "",
978          pct !== null ? tone(pct) : C.dim,
979          contextRows,
980        )}
981        {list.length > 0
982          ? frame(
983              "plan",
984              "plan",
985              `${done}/${list.length}`,
986              done === list.length ? C.ok : C.live,
987              planRows,
988            )
989          : null}
990        {runs.length > 0
991          ? frame("agents", "agents", agentsTitle, agentsColor, agentRows)
992          : null}
993        {gate.length > 0
994          ? frame(
995              "gate",
996              "gate",
997              `${gate.length} checks`,
998              n.denied > 0 ? C.bad : C.dim,
999              gateRows,
1000            )
1001          : null}
1002        {edits.length > 0
1003          ? frame(
1004              "changes",
1005              "changes",
1006              `${edits.length} files ${edits.some((c) => c.approx) ? "~" : ""}+${totalAdded} −${totalRemoved}`,
1007              C.dim,
1008              changeRows,
1009            )
1010          : null}
1011        {frame(
1012          "activity",
1013          focus ? `activity · ${clip(focus.description, 20)}` : "activity",
1014          "",
1015          C.dim,
1016          activityRows,
1017        )}
1018      </Box>
1019    );
1020  });
1021};
1022
hooks/model.ts 289 lines
1import type {
2  Change,
3  Check,
4  FeedKind,
5  Task,
6  TaskStatus,
7  Verdict,
8} from "../types";
9
10// Pure data: everything here is testable without the engine.
11
12type Args = Record<string, unknown>;
13type Block = { type: string; text?: string; thinking?: string };
14
15export const str = (v: unknown) => (typeof v === "string" ? v : "");
16export const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
17export const clip = (s: string, n: number) =>
18  s.length <= n ? s : `${s.slice(0, Math.max(0, n - 1))}…`;
19const isStatus = (v: unknown): v is TaskStatus =>
20  v === "pending" || v === "in_progress" || v === "completed";
21
22// ---------------------------------------------------------------- drawing
23
24// Colours are Claude Code theme keys, never hex: they follow the person's
25// light, dark or colour-blind theme, and this terminal rounds hex to 256
26// colours. Clay orange (`claude`) marks what is live; green, amber and red
27// carry status, as on any dashboard.
28export const C = {
29  live: "claude",
30  ok: "success",
31  warn: "warning",
32  bad: "error",
33  asked: "permission",
34  text: "text",
35  dim: "inactive",
36  frame: "subtle",
37} as const;
38
39// A dashboard tone for a percentage used: green, then amber from 50, red from 80.
40export const tone = (pct: number) =>
41  pct >= 80 ? C.bad : pct >= 50 ? C.warn : C.ok;
42
43const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
44
45// A smooth meter: whole cells, then an eighth-cell edge, then the empty track.
46export const meter = (pct: number, cells: number) => {
47  const width = Math.max(4, cells);
48  const exact = (Math.min(100, Math.max(0, pct)) / 100) * width;
49  // An edge that rounds up to a full eighth becomes another whole cell.
50  const eighths = Math.round(exact * 8);
51  const whole = Math.floor(eighths / 8);
52  const edge = EIGHTHS[eighths % 8] ?? "";
53  const on = "█".repeat(whole) + (whole < width ? edge : "");
54  return { on, off: "░".repeat(Math.max(0, width - [...on].length)) };
55};
56
57// A progress bar of `cells` cells (never fewer than 4), filled in proportion.
58export const bar = (done: number, total: number, cells: number) => {
59  const width = Math.max(4, cells);
60  const full = total === 0 ? 0 : Math.round((done / total) * width);
61  return `${"█".repeat(full)}${"░".repeat(width - full)}`;
62};
63
64export const duration = (ms: number) => {
65  const s = Math.max(0, Math.round(ms / 1000));
66  if (s < 60) return `${s}s`;
67  if (s < 3600)
68    return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
69  return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, "0")}m`;
70};
71
72// A running timer, as a stopwatch reads: 0:42, 12:05, 1h04.
73export const timer = (ms: number) => {
74  const s = Math.max(0, Math.floor(ms / 1000));
75  const m = Math.floor(s / 60);
76  return m < 60
77    ? `${m}:${String(s % 60).padStart(2, "0")}`
78    : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}`;
79};
80
81// Wall-clock time of day, HH:MM.
82export const clockTime = (ms: number) => {
83  const d = new Date(ms);
84  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
85};
86
87export const kTokens = (n: number) =>
88  n >= 1_000_000
89    ? `${(n / 1_000_000).toFixed(1)}M`
90    : n >= 1000
91      ? `${Math.round(n / 1000)}k`
92      : String(n);
93
94// claude-opus-5-5[1m] → Opus 5.5; anything unrecognised is shown as given.
95export const prettyModel = (id: string) => {
96  const m = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?![\d])/i.exec(id);
97  if (!m) return id ? clip(id, 18) : "—";
98  const name = m[1] ? m[1][0]?.toUpperCase() + m[1].slice(1) : "";
99  return `${name} ${m[2]}${m[3] ? `.${m[3]}` : ""}`;
100};
101
102// ---------------------------------------------------------------- activity
103
104// The one argument that says what a call is about, for the common tools.
105export const gist = (tool: string, a: Args) => {
106  const raw =
107    str(a.subject) ||
108    str(a.description) ||
109    str(a.command) ||
110    str(a.file_path) ||
111    str(a.pattern) ||
112    str(a.query) ||
113    str(a.url) ||
114    str(a.skill) ||
115    str(a.prompt);
116  const what = raw.startsWith("/") ? basename(raw) : raw;
117  return oneLine(what ? `${tool} ${what}` : tool);
118};
119
120// The feed rows one model response yields: its narration and any thinking
121// with visible text. Empty and redacted thinking (most of it, on current
122// models) is left out rather than shown as noise.
123export const responseItems = (blocks: readonly Block[]) =>
124  blocks.flatMap((b) => {
125    const kind: FeedKind = b.type === "thinking" ? "thinking" : "say";
126    const raw =
127      b.type === "thinking" ? b.thinking : b.type === "text" ? b.text : "";
128    const text = plain(str(raw));
129    return text ? [{ kind, text }] : [];
130  });
131
132// Narration as it reads, not as it was written: markdown marks, table rules
133// and fences dropped, so a row is the words alone.
134export const plain = (s: string) =>
135  oneLine(
136    s
137      .replace(/```[\s\S]*?```/g, " ")
138      .replace(/\*\*|__|`/g, "")
139      .replace(/^\s*(#{1,6}|[-*+]|\d+\.|>|\|)\s+/gm, "")
140      .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
141      .replace(/\s*\|\s*/g, " · ")
142      .replace(/(^|\s)[-:]{3,}(?=\s|$)/g, " "),
143  );
144
145// ---------------------------------------------------------------- checklist
146
147export const applyTodos = (list: Task[], todos: unknown): Task[] => [
148  ...list.filter((t) => !t.id.startsWith("todo:")),
149  ...(Array.isArray(todos) ? (todos as Args[]) : []).map((t, i) => ({
150    id: `todo:${i + 1}`,
151    subject: oneLine(str(t.content)),
152    status: isStatus(t.status) ? t.status : ("pending" as const),
153    activeForm: str(t.activeForm) || undefined,
154  })),
155];
156
157export const addTask = (
158  list: Task[],
159  made: { id: string; subject: string },
160  activeForm: unknown,
161): Task[] => [
162  ...list.filter((t) => t.id !== `task:${made.id}`),
163  {
164    id: `task:${made.id}`,
165    subject: oneLine(made.subject),
166    status: "pending",
167    activeForm: str(activeForm) || undefined,
168  },
169];
170
171export const updateTask = (list: Task[], a: Args): Task[] => {
172  const id = `task:${str(a.taskId)}`;
173  if (a.status === "deleted") return list.filter((t) => t.id !== id);
174  return list.map((t) =>
175    t.id === id
176      ? {
177          ...t,
178          subject: str(a.subject) || t.subject,
179          activeForm: str(a.activeForm) || t.activeForm,
180          status: isStatus(a.status) ? a.status : t.status,
181        }
182      : t,
183  );
184};
185
186// ---------------------------------------------------------------- gate
187
188export const verdictOf = (decision: string): Verdict =>
189  decision === "allow" ? "allowed" : decision === "deny" ? "denied" : "pending";
190
191// An ask is settled by the call around it: it ran (asked, then approved) or
192// it was refused.
193export const settle = (checks: Check[], id: string, didRun: boolean) =>
194  checks.map((c) =>
195    c.id === id && c.verdict === "pending"
196      ? { ...c, verdict: didRun ? ("asked" as const) : ("denied" as const) }
197      : c,
198  );
199
200export const tally = (checks: Check[]) => {
201  const n = { allowed: 0, asked: 0, pending: 0, denied: 0 };
202  for (const c of checks) n[c.verdict] += 1;
203  return n;
204};
205
206// ---------------------------------------------------------------- changes
207
208export const EDIT_TOOLS = new Set([
209  "Edit",
210  "MultiEdit",
211  "Write",
212  "NotebookEdit",
213]);
214
215// A final newline ends the last line; it does not start another.
216const lines = (s: unknown) =>
217  str(s) ? str(s).replace(/\n$/, "").split("\n").length : 0;
218const basename = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
219
220// Lines a hunk list adds and removes, or null when there are no hunks.
221const patchCount = (patch: unknown) => {
222  if (!Array.isArray(patch) || patch.length === 0) return null;
223  let added = 0;
224  let removed = 0;
225  for (const hunk of patch as { lines?: unknown }[])
226    for (const l of Array.isArray(hunk.lines) ? hunk.lines : []) {
227      if (typeof l !== "string") continue;
228      if (l.startsWith("+")) added += 1;
229      else if (l.startsWith("-")) removed += 1;
230    }
231  return { added, removed };
232};
233
234// Lines a successful edit added and removed. The tool's own diff is exact;
235// without one, the count falls back to the call's arguments and is marked
236// approximate, since whole bodies include unchanged lines.
237export const changeOf = (
238  tool: string,
239  a: Args,
240  out: Args = {},
241): Change | null => {
242  const file = str(a.file_path) || str(a.notebook_path);
243  if (!file || !EDIT_TOOLS.has(tool)) return null;
244  if (tool === "NotebookEdit") {
245    const mode = str(out.edit_mode) || str(a.edit_mode) || "replace";
246    const had = typeof out.old_source === "string";
247    const added = mode === "delete" ? 0 : lines(a.new_source);
248    const removed = mode === "insert" ? 0 : lines(out.old_source);
249    return mode === "insert" || had
250      ? { file, added, removed }
251      : { file, added, removed, approx: true };
252  }
253  const exact = patchCount(out.structuredPatch);
254  if (exact) return { file, ...exact };
255  if (tool === "Write" && out.originalFile === a.content)
256    return { file, added: 0, removed: 0 };
257  if (tool === "Write")
258    return out.type === "create"
259      ? { file, added: lines(a.content), removed: 0 }
260      : { file, added: lines(a.content), removed: 0, approx: true };
261  const edits =
262    tool === "MultiEdit" && Array.isArray(a.edits) ? (a.edits as Args[]) : [a];
263  const change = edits.reduce<Change>(
264    (sum, e) => ({
265      file,
266      added: sum.added + lines(e.new_string),
267      removed: sum.removed + lines(e.old_string),
268    }),
269    { file, added: 0, removed: 0 },
270  );
271  return { ...change, approx: true };
272};
273
274// Changes summed per file, the latest-touched file first.
275export const addChange = (list: Change[], c: Change): Change[] => {
276  const was = list.find((x) => x.file === c.file);
277  const merged = was
278    ? {
279        file: c.file,
280        added: was.added + c.added,
281        removed: was.removed + c.removed,
282        ...(was.approx || c.approx ? { approx: true } : {}),
283      }
284    : c;
285  return [merged, ...list.filter((x) => x.file !== c.file)];
286};
287
288export const shortFile = basename;
289
hooks/ticker.tsx 60 lines
1// A live clock with a spinner while it runs, ticking on the surface's frame
2// clock so the pane itself need not redraw. `since` and `now` come from the
3// hooks module's $.clock; between its redraws the ticker counts on its own.
4import type { ClientModule } from "claude-code";
5
6type Props = {
7  since: number;
8  now: number;
9  endAt: number | null;
10  color: string;
11  spin: boolean;
12};
13type Ref = { base: number; ticks: number; lastNow: number; isRunning: boolean };
14type State = { ref: Ref };
15
16const DOTS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
17
18const timer = (ms: number) => {
19  const s = Math.max(0, Math.floor(ms / 1000));
20  const m = Math.floor(s / 60);
21  return m < 60
22    ? `${m}:${String(s % 60).padStart(2, "0")}`
23    : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}`;
24};
25
26const Ticker: ClientModule<Props, State> = (props, surface) => {
27  const { Text } = surface.elements;
28  const ref = surface.state?.ref ?? {
29    base: 0,
30    ticks: 0,
31    lastNow: -1,
32    isRunning: true,
33  };
34  if (props.now !== ref.lastNow) {
35    ref.lastNow = props.now;
36    ref.base = (props.endAt ?? props.now) - props.since;
37    ref.ticks = 0;
38  }
39  ref.isRunning = props.endAt === null;
40  if (surface.state === undefined) {
41    surface.setState({ ref });
42    // Ten frames a second for the spinner; the clock advances every tenth.
43    surface.every(100, () => {
44      if (ref.isRunning) {
45        ref.ticks += 1;
46        surface.setState({ ref });
47      }
48    });
49  }
50  const elapsed = ref.base + Math.floor(ref.ticks / 10) * 1000;
51  return (
52    <Text color={props.color}>
53      {props.spin && ref.isRunning ? `${DOTS[ref.ticks % DOTS.length]} ` : ""}
54      {timer(elapsed)}
55    </Text>
56  );
57};
58
59export default Ticker;
60
hooks/loop.tsx 67 lines
1// The loop line: prompt › think › tool › result, the current step lit and a
2// shimmer running across it, as Claude Code's own spinner text shimmers. It
3// animates on the surface's frame clock, so only this line redraws.
4import type { ClientModule } from "claude-code";
5
6type Props = { phase: string; phases: string[] };
7type Ref = { tick: number; phase: string };
8type State = { ref: Ref };
9
10const SPIN = ["✻", "✳", "✢", "✶", "✢", "✳"];
11const STEP_MS = 120;
12
13const Loop: ClientModule<Props, State> = (props, surface) => {
14  const { Box, Text } = surface.elements;
15  const ref = surface.state?.ref ?? { tick: 0, phase: props.phase };
16  ref.phase = props.phase;
17  if (surface.state === undefined) {
18    surface.setState({ ref });
19    surface.every(STEP_MS, () => {
20      if (ref.phase !== "idle") {
21        ref.tick += 1;
22        surface.setState({ ref });
23      }
24    });
25  }
26
27  const isIdle = props.phase === "idle";
28  const at = props.phases.indexOf(props.phase);
29  return (
30    <Box>
31      <Text color={isIdle ? "inactive" : "claude"} bold>
32        {isIdle ? "○ " : `${SPIN[ref.tick % SPIN.length]} `}
33      </Text>
34      {props.phases.map((name, i) => {
35        const sep = i === 0 ? "" : " › ";
36        if (i !== at) {
37          // Steps already taken this cycle stay readable; those ahead are faint.
38          return (
39            <Text color={i < at ? "text" : "subtle"} dimColor={i < at}>
40              {sep}
41              {name}
42            </Text>
43          );
44        }
45        // The live step: a three-letter shimmer sweeping across the word.
46        const word = name.toUpperCase();
47        const head = ref.tick % (word.length + 3);
48        return (
49          <Text>
50            <Text color="subtle">{sep}</Text>
51            {[...word].map((ch, j) => (
52              <Text
53                color={Math.abs(j - head + 1) <= 1 ? "claudeShimmer" : "claude"}
54                bold
55              >
56                {ch}
57              </Text>
58            ))}
59          </Text>
60        );
61      })}
62    </Box>
63  );
64};
65
66export default Loop;
67
types/index.d.ts 70 lines
1export type TaskStatus = "pending" | "in_progress" | "completed";
2
3// One checklist row. `id` is namespaced by source (`task:3`, `todo:1`) so the
4// task tools and TodoWrite never overwrite each other's rows.
5export type Task = {
6  id: string;
7  subject: string;
8  status: TaskStatus;
9  activeForm?: string;
10};
11
12export type AgentState = "running" | "waiting" | "done" | "failed" | "killed";
13
14export type AgentRun = {
15  id: string;
16  description: string;
17  type: string;
18  status: AgentState;
19  startedAt: number;
20  endedAt?: number;
21  tools: number;
22  // The agent's context now: the whole input of its latest model request.
23  ctx: number;
24};
25
26// Where the main loop is in its cycle.
27export type Phase = "idle" | "prompt" | "think" | "tool" | "result";
28
29export type Verdict = "allowed" | "asked" | "pending" | "denied";
30
31export type Check = { id: string; tool: string; verdict: Verdict };
32
33// approx: a replace_all edit was counted once, so the totals are a floor.
34export type Change = {
35  file: string;
36  added: number;
37  removed: number;
38  approx?: boolean;
39};
40
41export type FeedKind = "tool" | "thinking" | "say" | "agent" | "deny";
42
43export type FeedItem = {
44  at: number;
45  agentId?: string;
46  kind: FeedKind;
47  text: string;
48};
49
50export type Loop = {
51  model: string;
52  phase: Phase;
53  turnStartedAt: number | null;
54  turnEndedAt: number | null;
55  compactions: number;
56};
57
58declare module "claude-code" {
59  interface PluginState {
60    glassbox: {
61      tasks: Task[];
62      agents: AgentRun[];
63      feed: FeedItem[];
64      checks: Check[];
65      changes: Change[];
66      loop: Loop;
67    };
68  }
69}
70