SLOPSHOPPER

bq-vitals

Status line with context, cost and rate limits, plus a live state-machine map and query-plan style trace for reactive skill runs.

newpanecommandstatusprocesstimer
★ 1v0.1.0AGPL-3.0updated 2026-10-06Reactive-Skills/reactive-skills/mods/bq-vitals
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · bq-vitals
│ ┃ Reactive run ✕ › fix the failing auth test and add an audit log call │ ┃ No reactive skill run found yet. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /flow │ ⎿ bq-vitals: Reactive run pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ bq-vitals: ctx 97k/200k 49% · $0.42 · 5h 31%

Draws

Pane · Reactive run
No reactive skill run found yet.
README

⚡ Reactive Skills Architecture (RSA)

Event-Driven Hierarchical State Machine Engine and Immutable Event Store for Agentic Skills

CI License: AGPL v3 npm version Release Notes


🚀 What's New in v0.19.0:

  • New reactive-skills-axi vet <skill|dir> statically checks a skill's guard code, text, and dependencies for risky patterns before you run it. It is the first part of the trust work; a trust record and a hardened mode are still to come.
  • validate catches a guard file in the wrong module format before it runs, and <command> --help now works on every command.
  • invoke rejects a wrapped contextUpdates payload and shows the expected shape; jobs and reset find runs by skill path or name.
  • Judgments can limit what the AI judge sees with context_paths and include_payload; a legacy mixed run store migrates instead of crashing invoke.

Read Full Release Notes → · View Changelog


🌟 What is Reactive Skills?

Conventional agent skills are static markdown instruction files (SKILL.md). An LLM reads all instructions upfront, enters an unstructured execution loop, and guesses its next steps without state verification or deterministic progress guarantees.

Reactive Skills upgrade passive agent skills into Hierarchical State Machines (HSM) driven by a reactive Event/Signal Bus:

  1. Just-In-Time Prompt Slices: Only the prompt, constraints, and tool whitelists for the active state are loaded into the LLM turn (~70% token reduction).
  2. Signal-Driven Ingress: Transitions are triggered by typed runtime signals (REQUIREMENTS_GATHERED, TESTS_PASSED, USER_APPROVED).
  3. Deterministic Guard Gates: Progress requires deterministic programmatic invariants to evaluate true (preventing hallucinated completion).
  4. Event Sourcing & Live Deliverables: Every transition is recorded to an append-only ledger (events.jsonl + SQLite events.db). Documentation, PR bodies, and review matrices are live read-model projections rendered from the event stream.

📦 Packages

This repository is a monorepo containing:

  • @reactive-skills/runtime: The core TypeScript statechart engine, SQLite event store driver, guard evaluator, and stdio MCP server.
  • @reactive-skills/axi: The Agent eXperience Interface (AXI) CLI (reactive-skills-axi) providing human- and agent-ergonomic state inspection and signal dispatch in TOON format.

🚀 Quickstart

1. Execution Options

You can execute commands on-demand via npx (zero installation required) or install the CLI globally:

# Zero install — works immediately for any agent or shell:
npx -y @reactive-skills/axi state <skill-name>
npx -y @reactive-skills/axi emit <skill-name> <signal-name>
npx -y @reactive-skills/axi capabilities --json
npx -y @reactive-skills/axi preflight <skill-name> --json

# Optional: Install globally for instant local commands (`reactive-skills-axi` or `axi`):
npm install -g @reactive-skills/axi

2. Inspect a Skill's Current State

npx -y @reactive-skills/axi state <skill-name>

Outputs the current state, active prompt instructions, allowed tools, and available transitions in structured format:

state:
  skill_id: my-skill
  current_state: STEP_ONE
prompt:
  raw_prompt: "# State: Processing Step One ..."
  allowed_tools: "view_file,grep_search,find_by_name,ask_question"
help[2]:
  Read the state prompt above and execute the instructed tasks
  Run `npx -y @reactive-skills/axi emit <skill> <signal>` to advance

3. Emit a Signal to Advance

npx -y @reactive-skills/axi emit <skill-name> <signal-name> [--payload '{"exit_code":0}']

The state machine evaluates transition guards, appends to the immutable event ledger, updates deliverables, and outputs the next state instructions.


🔌 Integration Modes

Reactive skills can be driven through two primary integration paths:

  1. AXI CLI (Universal Shell Mode): Any agent capable of running terminal commands can drive the skill using npx -y @reactive-skills/axi state <skill> (or reactive-skills-axi state <skill>) and npx -y @reactive-skills/axi emit <skill> <signal>.
  2. MCP Server (Model Context Protocol): Exposes reactive_context_prepare, reactive_state, and reactive_emit_signal tools over stdio for Claude Desktop, Antigravity, Cursor, and any MCP-compatible harness: ``bash npx -y @reactive-skills/axi mcp ``

Call reactive_context_prepare with the current user message before loading full skill instructions. The tool uses Jev to select one skill and returns only its metadata, active state, or bounded instructions. If Jev is unavailable, it returns route: none and the agent can continue its directly requested runtime path.


🛠️ CLI Reference

Note: Commands below are shown using the zero-install npx -y @reactive-skills/axi prefix. If installed globally (npm install -g @reactive-skills/axi), you can substitute reactive-skills-axi or axi.

CommandUsageDescription
statenpx -y @reactive-skills/axi state <skill>Display current active state, unabridged prompt slice, and allowed tools
emitnpx -y @reactive-skills/axi emit <skill> <signal>Emit a signal to evaluate guards and advance to the next state; --payload is the signal payload, with context changes under contextUpdates
invokenpx -y @reactive-skills/axi invoke <skill> [--payload JSON]Initialize and start a skill run; --payload is a flat object that becomes the initial context (a {"contextUpdates":{...}} wrapper is rejected)
eventsnpx -y @reactive-skills/axi events [limit] <skill>Tail recent events from the append-only event store
inspectnpx -y @reactive-skills/axi inspect <skill>Print statechart topology, substates, and guard rules
validatenpx -y @reactive-skills/axi validate [path]Validate skill manifest, prompt templates, transition targets, and bootloader
vetnpx -y @reactive-skills/axi vet <path> [--fail-on <severity>] [--allowlist <file>]Statically scan skills for risky code, prompt injection, hidden content, and download-and-execute steps before you run them; see docs/vetting.md
initnpx -y @reactive-skills/axi init <name>Scaffold a new modular reactive skill package
resetnpx -y @reactive-skills/axi reset <skill>Clear execution run state while preserving deliverables
jobs`npx -y @reactive-skills/axi jobs <skill> [list\switch\archive]`Inspect, switch, and archive isolated execution runs and deliverables
viewnpx -y @reactive-skills/axi view <skill>Launch real-time telemetry server and live visual statechart viewer
dashboardnpx -y @reactive-skills/axi dashboard [--host <host>] [--port <port>]Launch one read-only broker for multi-job telemetry
syncnpx -y @reactive-skills/axi sync [skill]Copy ordered sources into a physical central directory, then update linked or physical satellites
capabilitiesnpx -y @reactive-skills/axi capabilities --jsonReport runtime version and capabilities for INIT negotiation
bootloadernpx -y @reactive-skills/axi bootloader <skill> --jsonRetrieve the versioned authoritative runtime bootloader
preflightnpx -y @reactive-skills/axi preflight <skill>Check skill runtime requirements without creating a job

🛠️ Authoring Reactive Skills

Avoid manually hand-authoring reactive skill packages from scratch. A reactive skill binds together statechart topology (skill.yaml), individual state prompt templates (states/*.md), deterministic guards (guards/), projection deliverables (templates/*.hbs), and strict execution bootloaders. Hand-authoring these files easily introduces syntax drift, broken transitions, or missing guards.

Recommended: Use the skill-manager Skill

The canonical method to scaffold, modify, and migrate reactive skills is the skill-manager agent skill.

Instead of writing YAML manifests manually, ask your AI agent:

"Use skill-manager to create a reactive skill named my-feature-workflow"

The skill-manager skill validates schemas, coordinates state prompts with strict execution invariants, and handles lifecycle operations (CREATE, UPDATE, MIGRATE_LEGACY, MIGRATE_REACTIVE).

Visual Statecharts with STATECHART.md

skill-manager supports and generates a STATECHART.md alongside skill.yaml. This document contains a Mermaid stateDiagram-v2 visualization of the entire state machine:

stateDiagram-v2
    [*] --> INIT
    INIT --> START : RUNTIME_READY
    INIT --> SETUP_MCP : SETUP_REQUIRED
    SETUP_MCP --> START : SETUP_COMPLETE [exit_code == 0]
    SETUP_MCP --> ERROR : SETUP_FAILED [exit_code != 0]
    START --> DONE : DONE
    DONE --> [*]
  • Visual-first design: You can sketch a proposed state machine using a Mermaid stateDiagram-v2 block in STATECHART.md, and skill-manager will parse it into the corresponding skill.yaml and state templates.
  • Continuous synchronization: When modifying a skill's states or transitions, skill-manager updates both skill.yaml and STATECHART.md to keep documentation and runtime contracts identical.

Terminal Scaffolding (init)

For quick command-line scaffolding, you can also use the AXI CLI:

npx -y @reactive-skills/axi init <skill-name>

This scaffolds the modular file layout in skills/<skill-name>/:

skills/<skill-name>/
├── skill.yaml            # Statechart manifest (states, transitions, guards)
├── STATECHART.md         # Visual Mermaid statechart diagram
├── SKILL.md              # Skill entry point with reactive bootloader
├── states/               # State-specific markdown prompt templates
│   ├── init.md
│   ├── setup_mcp.md
│   ├── start.md
│   ├── done.md
│   └── bypass_detected.md
└── skill-release.json    # Release and schema metadata

🔄 Syncing & Distributing Skills

The synchronizer copies skills from ordered local source directories into a physical central directory (default ~/.agents/skills). The first source containing a skill name wins. Agent satellite directories then link to the central copy. Changes in a source repository reach the central directory on the next sync; linked satellites see those central changes immediately. Satellites that cannot read links receive physical copies, refreshed on each sync when their content changes.

Only immediate child folders containing SKILL.md, skill.md, or skill.yaml are distributed. Unrelated folders at a source root are ignored. Sync reads local files, or committed files when a ref is set; it never fetches Git updates. Within a selected skill, sync preserves every file and directory, including scripts, tests, hidden files and empty directories. Nested symbolic links remain links; sync does not traverse their targets. Repository-folder exclusions apply only when discovering skills at the source root.

Configure the layout in ~/.agents/sync.json:

{
  "sources": ["~/work/public-skills", "~/work/private-skills"],
  "central": "~/.agents/skills",
  "satellites": ["~/.claude/skills", "~/.codex/skills", "~/.gemini/config/skills"],
  "physicalSatellites": ["~/.gemini/config/skills"]
}

Change the persistent central directory by updating the central value in this file. Use --central <dir> to override it for one invocation, and use --show-config to inspect the resolved paths. When prior sync state records the old central path, the next sync can use that directory as a migration fallback while populating the new central directory. The old central directory remains on disk.

Sources are optional. Without them, sync distributes valid skills already in the central directory. Without a config file, it uses the default central path and existing agent directories as satellites; the physical satellite list is empty. If a directory appears in both satellite lists, the physical copy takes precedence. If the central path appears in either satellite list, sync omits it. When sync.json is absent, existing sources from ~/.agents/sources.json remain a fallback. If creating sync.json for the first time, copy any needed source paths into its sources array because the legacy source fallback applies only when sync.json is absent.

Git Sources, Refs, and Downgrade Protection

By default, sync copies each skill from the source folder's working tree as it is at that moment. That means checking out an older branch in a source repository changes what the next sync installs. Three safeguards keep that from replacing newer installed skills silently.

  • Pin a ref. A source entry can be an object with a ref (branch, tag, or commit). Sync then reads the committed files at that ref with git ls-tree and git cat-file, so the source can have any branch checked out, dirty or not. Nothing is checked out and the source repository is never modified. The --ref <ref> flag does the same for every source in one run and overrides configured refs. A ref on a source that is not a git repository, or one that does not resolve, stops the sync with an error.
  • Downgrade protection. Before replacing an installed skill whose content differs, sync compares the version in each skill.yaml using SemVer. A source version lower than the installed one is refused: the installed copy stays unchanged, the report names the skill and both versions, and the command exits with status 1. Other skills still sync. Pass --allow-downgrade to replace it anyway; the usual backup is still made. A same-version content change and a skill whose version is missing or not SemVer (for example 1.0 or a legacy SKILL.md skill) produce a warning and are replaced. The check applies to --dry-run and to sources that are not git repositories.
  • Working-tree warnings. When a git source has no ref, sync warns if it is on a branch other than the default branch (the origin/HEAD branch, else main or master) or is on a detached HEAD, and warns if the selected skills have uncommitted or untracked changes. Each warning names the source, the branch, and the skills involved. Warnings do not stop the sync.
{
  "sources": [
    "~/work/private-skills",
    { "path": "~/work/public-skills", "ref": "main" }
  ]
}

Sync records where each installed skill came from under skills in ~/.agents/sync-state.json: the source path, the ref or checked-out branch, the commit SHA, the skill.yaml version, and dirty: true when the working-tree copy had uncommitted changes. For a working-tree sync the SHA is the source's HEAD, so check dirty as well. Sources that are not git repositories record only the source path and version. The record is merged on each run, so syncing one skill keeps the others, and a skill that is refused keeps its previous record. sync --show-config prints the configured refs and the recorded provenance, and a sync prints the provenance of the skills it copied. Empty directories are not part of a commit, so they are not copied when a ref is used.

CLI Synchronization

# Show effective paths and source precedence:
npx -y @reactive-skills/axi sync --show-config

# Preview a one-run central directory override:
npx -y @reactive-skills/axi sync --central ~/work/skill-registry --dry-run

# Preview central copies, satellite links/copies, and source collisions:
npx -y @reactive-skills/axi sync --dry-run

# Synchronize all valid skills:
npx -y @reactive-skills/axi sync

# Synchronize a specific skill only:
npx -y @reactive-skills/axi sync <skill-name>

# Synchronize several selected skills together:
npx -y @reactive-skills/axi sync --skill skill-one,skill-two
# Repeated --skill flags remain supported:
npx -y @reactive-skills/axi sync --skill skill-one --skill skill-two

# Override configured sources with comma-separated paths:
npx -y @reactive-skills/axi sync --source ~/work/public-skills,~/work/private-skills --dry-run

# Add explicit sources before configured sources:
npx -y @reactive-skills/axi sync --source ~/work/public-skills --all-sources --dry-run

# Choose linked and physical satellite directories:
npx -y @reactive-skills/axi sync --target ~/.codex/skills,~/.claude/skills --physical-target ~/.gemini/config/skills,~/.copilot/skills --dry-run

# Install the committed skills of main, whatever the sources have checked out:
npx -y @reactive-skills/axi sync --ref main

# Replace an installed skill with an older source version:
npx -y @reactive-skills/axi sync <skill-name> --allow-downgrade

# Preview changes without modifying files:
npx -y @reactive-skills/axi sync <skill-name> --dry-run

# Use physical copies for all selected satellites for this run:
npx -y @reactive-skills/axi sync <skill-name> --copy

Each --source, --target, and --physical-target value can contain comma-separated paths, and each option can be repeated. Surrounding whitespace is ignored, and empty path entries are rejected.

Model Context Protocol (MCP)

Agents running in GUI environments can call the native reactive_sync tool:

{
  "skill": "my-skill",
  "link": true,
  "dryRun": false
}

Safety and Backups

The synchronizer validates path overlaps before writing. It stages and verifies central copies before replacing existing skill folders. It backs up physical directories before replacement under each target's .sync-backups/ directory. Valid skills already in the central directory remain there when no configured source supplies them. A state file at ~/.agents/sync-state.json tracks links created by sync, allowing it to remove obsolete managed links when satellite configuration changes while preserving unrelated files.


🗂️ Job & Run Isolation (Multi-Run Management)

RSA provides multi-run isolation, allowing teams and autonomous agents to execute multiple independent runs of the same skill without event log pollution or deliverable overwrites.

Storage Architecture

  • Isolated Event Ledgers: Each run maintains its own scoped event ledger under .reactive/skills/<skill>/jobs/<job-id>/ with independent events.jsonl and SQLite events.db stores.
  • Active Job Pointer: The active run pointer is tracked at .reactive/skills/<skill>/active_job (defaults to default). All CLI commands and MCP operations target the active run unless explicitly overridden.
  • Dual-Write Deliverable Mirroring: Projections write to .docs/<skill>/jobs/<job-id>/ for permanent archival, and automatically mirror to the canonical .docs/ path for the active job.
  • Template Context: Handlebars templates receive jobId directly inside ProjectionContext, enabling deliverables to reference their run ID.

CLI Run Management

# Start a fresh execution run (auto-generates sortable job ID)
npx -y @reactive-skills/axi invoke <skill> [--payload JSON]

# Start an isolated named run (does not change the global active pointer)
npx -y @reactive-skills/axi invoke <skill> --job <job-id>

# Inspect or resume the active execution run (auto-rotates if prior job is terminal)
npx -y @reactive-skills/axi state <skill>

# Run with environment-scoped isolation (parallel subagents)
REACTIVE_JOB_ID=subagent-1 npx -y @reactive-skills/axi state <skill>

# List all execution runs for a skill (active job highlighted)
npx -y @reactive-skills/axi jobs <skill> list
# (or bidirectional syntax: npx -y @reactive-skills/axi jobs list <skill>)

# Switch the active execution run
npx -y @reactive-skills/axi jobs <skill> switch <job-id>

# Archive a completed run
npx -y @reactive-skills/axi jobs <skill> archive <job-id>

# Target a specific job explicitly without switching active pointer
npx -y @reactive-skills/axi state <skill> --job <job-id>
npx -y @reactive-skills/axi emit <skill> <signal> --job <job-id>

Model Context Protocol (MCP) Tools

When interacting with agents over MCP, job operations are exposed as native tools:

  • reactive_bootloader: Retrieves the versioned authoritative bootloader for a reactive skill.
  • reactive_list_jobs: Lists all runs for a skill with state and active indicators.
  • reactive_switch_job: Switches the active job pointer.
  • reactive_archive_job: Marks a job run as archived.

📊 Telemetry & Performance Metrics

RSA provides real-time performance instrumentation and token economy tracking with zero runtime latency tax.

Inspecting Metrics from the Event Ledger

Every state transition records execution telemetry inside .reactive/skills/<skill>/events.jsonl:

{
  "type": "STATE_TRANSITION",
  "payload": {
    "from": "RED_SPEC",
    "to": "GREEN_CODE",
    "metrics": {
      "transition_duration_ms": 1.151,
      "slice_duration_ms": 0.922,
      "slice_tokens_est": 282
    }
  }
}

Tail latest transition metrics from your terminal:

# PowerShell
Get-Content .reactive/skills/<skill>/events.jsonl | ConvertFrom-Json | Where-Object { $_.type -eq "STATE_TRANSITION" } | Select-Object -ExpandProperty payload | Select-Object from, to, metrics

# SQLite
sqlite3 .reactive/skills/<skill>/events.db "SELECT seq, json_extract(payload, '$.metrics') FROM events WHERE type = 'STATE_TRANSITION';"

Real-Time Telemetry Dashboard

Launch the existing single-job viewer and SSE event stream:

npx -y @reactive-skills/axi view <skill-name>                  # prefer 4242, then try the bounded fallback range
npx -y @reactive-skills/axi view <skill-name> --port 5000     # bind only to 5000
npx -y @reactive-skills/axi view <skill-name> --port 0        # ask the OS for an ephemeral port
npx -y @reactive-skills/axi view <skill-name> --job sprint-1  # follow one job without changing the active pointer

Launch the read-only multi-job broker:

npx -y @reactive-skills/axi dashboard
npx -y @reactive-skills/axi dashboard --port 0
npx -y @reactive-skills/axi dashboard --host 0.0.0.0 --port 4500

The command reports the actual listener URL and bound port.

Open the site /telemetry route and enter that broker URL in the connection field.

The broker catalog discovers skills and jobs from the current workspace's .reactive/skills directory and returns skill names, job IDs, status, current HSM state, local latest sequence, update time, and active-job metadata.

The dashboard uses GET /catalog, job-scoped GET /state?skillId=<skill-id>&jobId=<job-id>, and one filtered GET /events SSE connection for all tracked targets.

Add two jobs from the catalog, such as jsm-workflow/review-slice and jsm-workflow/test-slice, to monitor them simultaneously.

Every card verifies both skill ID and job ID before accepting an event, so a signal written to one job cannot update another card.

The broker tails each job's SQLite event store and refreshes the catalog on a bounded interval, so new jobs and cross-process events appear without restarting it.

The broker is read-only telemetry.

It does not dispatch signals, write events, change the active-job pointer, or scan arbitrary browser localhost ports.

The existing view <skill> --job <job-id> command remains single-job scoped for explicit isolation and backward compatibility.

The broker keeps th

Source 2 files
hooks/register.tsx 275 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Edge, Flow, Step } from '../types'
5
6const PANE = 'bq-flow'
7const TERMINAL = new Set(['COMPLETE', 'ERROR'])
8const flowAtom = atom({ plugin: 'bq-vitals', key: 'flow' } as const, null)
9
10type Ev = {
11  type: string
12  state: string
13  timestamp: string
14  run_id: string
15  skill_id: string
16  payload: Record<string, unknown>
17}
18
19export function fmtTokens(n: number): string {
20  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
21  if (n >= 1000) return `${Math.round(n / 1000)}k`
22  return String(n)
23}
24
25export function fmtMs(ms: number): string {
26  if (ms < 1000) return `${Math.round(ms)}ms`
27  const s = ms / 1000
28  if (s < 60) return `${s.toFixed(1)}s`
29  return `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, '0')}s`
30}
31
32// `A --> B: SIG [guards]` or `A --> B: SIG1 / SIG2` lines of a STATECHART.md.
33export function parseChart(text: string): Edge[] {
34  const edges: Edge[] = []
35  for (const line of text.split('\n')) {
36    const m = line.match(/^\s*([A-Z][A-Z_]*)\s*-->\s*([A-Z][A-Z_]*)(?:\s*:\s*(.*))?$/)
37    if (!m) continue
38    const signals = (m[3] ?? '')
39      .replace(/\[.*$/, '')
40      .split('/')
41      .map(s => s.trim())
42      .filter(Boolean)
43    edges.push({ from: m[1], to: m[2], signals })
44  }
45  return edges
46}
47
48function stateOrder(edges: Edge[], steps: Step[], current: string): string[] {
49  const order: string[] = []
50  const add = (s: string) => {
51    if (!order.includes(s)) order.push(s)
52  }
53  for (const e of edges) {
54    add(e.from)
55    add(e.to)
56  }
57  for (const s of steps) {
58    add(s.from)
59    add(s.to)
60  }
61  add(current)
62  return order
63}
64
65// Turns one run's event log into the path it took, like an EXPLAIN of the run.
66export function buildFlow(lines: string[], edges: Edge[], now: number): Flow | null {
67  const all: Ev[] = []
68  for (const line of lines) {
69    try {
70      all.push(JSON.parse(line) as Ev)
71    } catch {
72      // a half-written last line
73    }
74  }
75  const last = all[all.length - 1]
76  if (!last) return null
77  const evs = all.filter(e => e.run_id === last.run_id)
78  const steps: Step[] = []
79  let current = 'INIT'
80  let enteredAt = Date.parse(evs[0].timestamp)
81  const startedAt = enteredAt
82  let signalAt = enteredAt
83  let denied = 0
84  let guard: Step['guard'] = 'none'
85  for (const e of evs) {
86    const t = Date.parse(e.timestamp)
87    if (e.type === 'SIGNAL_EMITTED') {
88      signalAt = t
89      denied = 0
90      guard = 'none'
91    } else if (e.type === 'GUARD_EVALUATED') {
92      if (e.payload.passed === false) denied += 1
93      else guard = e.payload.fallbackTriggered === true ? 'fallback' : 'pass'
94    } else if (e.type === 'STATE_TRANSITION') {
95      const from = String(e.payload.from)
96      const to = String(e.payload.to)
97      const signal = String(e.payload.signal)
98      const taken = edges.filter(x => x.from === from).flatMap(x => x.signals)
99      steps.push({
100        from,
101        to,
102        signal,
103        guard,
104        denied,
105        dwellMs: Math.max(0, signalAt - enteredAt),
106        notTaken: [...new Set(taken.filter(s => s !== signal))],
107      })
108      current = to
109      enteredAt = t
110    }
111  }
112  const updatedAt = Date.parse(last.timestamp)
113  const isTerminal = TERMINAL.has(current)
114  return {
115    skill: last.skill_id,
116    runId: last.run_id,
117    current,
118    isTerminal,
119    startedAt,
120    updatedAt,
121    liveMs: isTerminal ? 0 : Math.max(0, now - enteredAt),
122    order: stateOrder(edges, steps, current),
123    edges,
124    steps,
125  }
126}
127
128// Plain-text plan: the path taken, per hop the signal, guard, time and roads not taken.
129export function planText(f: Flow): string {
130  const total = f.steps.reduce((a, s) => a + s.dwellMs, 0) + f.liveMs
131  const slowest = Math.max(1, ...f.steps.map(s => s.dwellMs), f.liveMs)
132  const bar = (ms: number) => '█'.repeat(Math.max(1, Math.round((ms / slowest) * 10)))
133  const out = [
134    `${f.skill}  run ${f.runId.slice(0, 8)}  ${f.isTerminal ? `finished at ${f.current}` : `running at ${f.current}`}  work ${fmtMs(total)}`,
135  ]
136  f.steps.forEach((s, i) => {
137    const mark = s.guard === 'fallback' ? '↯' : '✔'
138    const denied = s.denied > 0 ? `  guard denied ${s.denied}x first` : ''
139    out.push(`${String(i + 1).padStart(2)}. ${s.from} ─${s.signal}→ ${s.to}  ${mark} ${bar(s.dwellMs)} ${fmtMs(s.dwellMs)}${denied}`)
140    if (s.notTaken.length > 0) out.push(`      not taken: ${s.notTaken.join(', ')}`)
141  })
142  if (!f.isTerminal) out.push(`    ▶ ${f.current}  ${bar(f.liveMs)} ${fmtMs(f.liveMs)} so far`)
143  return out.join('\n')
144}
145
146function statusLine(f: Flow | null, now: number): string | undefined {
147  if (f === null) return undefined
148  const idle = now - f.updatedAt
149  if (idle > 15 * 60_000 || (f.isTerminal && idle > 2 * 60_000)) return undefined
150  return `${f.skill} ▸ ${f.current}`
151}
152
153const FIND = [
154  'files=$(stat -c "%Y %n" "$PWD"/.reactive/skills/*/events.jsonl',
155  '"$HOME"/.claude/skills/*/.reactive/skills/*/events.jsonl',
156  '"$HOME"/.agents/skills/*/.reactive/skills/*/events.jsonl',
157  '/tmp/.reactive/skills/*/events.jsonl 2>/dev/null | sort -rn | head -1 | cut -d" " -f2-);',
158  '[ -n "$files" ] && printf "%s" "$files"',
159].join(' ')
160
161type Engine = EngineInterface
162type Mem = { home: string | null; isPaneOpen: boolean; usageText: string; edgeCache: Map<string, Edge[]> }
163
164async function loadFlow($: Engine, mem: Mem): Promise<Flow | null> {
165  try {
166    const found = await $.process.run(['sh', '-c', FIND], { timeoutMs: 5000 })
167    const path = found.stdout.trim()
168    if (path === '') return null
169    const skill = path.split('/').slice(-2)[0]
170    if (!mem.edgeCache.has(skill)) {
171      mem.home ??= (await $.process.run(['sh', '-c', 'printf %s "$HOME"'])).stdout
172      let edges: Edge[] = []
173      for (const root of [`${mem.home}/.claude/skills`, `${mem.home}/.agents/skills`]) {
174        try {
175          edges = parseChart(await $.fs.read(`${root}/${skill}/STATECHART.md`))
176          break
177        } catch {
178          // try the next root
179        }
180      }
181      mem.edgeCache.set(skill, edges)
182    }
183    const text = await $.fs.read(path)
184    return buildFlow(text.split('\n').filter(Boolean), mem.edgeCache.get(skill) ?? [], await $.clock.now())
185  } catch {
186    return null
187  }
188}
189
190async function refresh($: Engine, mem: Mem) {
191  try {
192    const u = await $.session.usage()
193    const ctx = u.context
194    const parts = [
195      ctx.tokens === undefined
196        ? `ctx ${fmtTokens(ctx.window)}`
197        : `ctx ${fmtTokens(ctx.tokens)}/${fmtTokens(ctx.window)} ${Math.round(ctx.percent ?? 0)}%`,
198    ]
199    if (u.cost) parts.push(`$${u.cost.usd.toFixed(2)}`)
200    for (const r of u.rateLimits) {
201      parts.push(`${r.kind === 'five_hour' ? '5h' : r.kind === 'seven_day' ? '7d' : r.kind} ${Math.round(r.percentUsed)}%`)
202    }
203    mem.usageText = parts.join(' · ')
204  } catch {
205    // keep the last reading
206  }
207  const flow = await loadFlow($, mem)
208  const flowText = statusLine(flow, await $.clock.now())
209  $.ui.status([mem.usageText, flowText].filter(Boolean).join('  |  ') || undefined)
210  if (mem.isPaneOpen) await update($, flowAtom, () => flow)
211}
212
213export const register: Register = on => {
214  const mem: Mem = { home: null, isPaneOpen: false, usageText: '', edgeCache: new Map() }
215
216  on('session.start', async ($, e, next) => {
217    await $.command.register({ name: 'flow', description: 'Live map and query-plan style trace of the latest reactive skill run' })
218    await $.command.register({ name: 'flow-plan', description: 'Print the path the latest reactive skill run took, as text' })
219    $.clock.every(3000, () => void refresh($, mem))
220    void refresh($, mem)
221    return next(e)
222  })
223
224  on('turn.complete', async ($, e, next) => {
225    await refresh($, mem)
226    return next(e)
227  })
228
229  on('command.run', { command: 'flow' }, async $ => {
230    mem.isPaneOpen = true
231    await update($, flowAtom, () => null)
232    await $.ui.open({ id: PANE, title: 'Reactive run' })
233    void refresh($, mem)
234    return { text: 'Reactive run pane opened.' }
235  })
236
237  on('command.run', { command: 'flow-plan' }, async $ => {
238    const flow = await loadFlow($, mem)
239    return { text: flow === null ? 'No reactive skill run found.' : planText(flow) }
240  })
241
242  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
243    const { Box, Text } = $.ui.resolve(e)
244    const flow = await read($, flowAtom)
245    if (flow === null) return <Text dimColor>No reactive skill run found yet.</Text>
246
247    const visited = new Set(flow.steps.flatMap(s => [s.from, s.to]))
248    const path = flow.steps.map(s => s.from)
249    return (
250      <Box flexDirection="column">
251        <Text bold>{flow.skill} · run {flow.runId.slice(0, 8)}</Text>
252        <Text dimColor>{' '}</Text>
253        {flow.order.map(state => {
254          const isNow = state === flow.current
255          const isDone = path.includes(state) && !isNow
256          const mark = isNow ? '▶' : isDone ? '✔' : visited.has(state) ? '·' : '○'
257          return (
258            <Text bold={isNow} dimColor={!isNow && !isDone}>
259              {mark} {state}
260            </Text>
261          )
262        })}
263        <Text dimColor>{' '}</Text>
264        <Text bold>Plan</Text>
265        {planText(flow)
266          .split('\n')
267          .slice(1)
268          .map(line => (
269            <Text dimColor={line.includes('not taken')}>{line}</Text>
270          ))}
271      </Box>
272    )
273  })
274}
275
types/index.d.ts 33 lines
1export type Edge = { from: string; to: string; signals: string[] }
2
3// One hop through the state machine, read from the run's event log.
4export type Step = {
5  from: string
6  to: string
7  signal: string
8  // pass: the guard allowed it; fallback: the guard failed and a fallback ran.
9  guard: 'pass' | 'fallback' | 'none'
10  denied: number
11  dwellMs: number
12  notTaken: string[]
13}
14
15export type Flow = {
16  skill: string
17  runId: string
18  current: string
19  isTerminal: boolean
20  startedAt: number
21  updatedAt: number
22  liveMs: number
23  order: string[]
24  edges: Edge[]
25  steps: Step[]
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'bq-vitals': { flow: Flow | null }
31  }
32}
33